Просмотр исходного кода

feat(codex): Responses↔Chat 翻译层(P2)

chatBridgeTranslate.ts:responsesRequestToChat(instructions→developer、四类 input item 映射、tools 扁平→嵌套、max_output_tokens→max_tokens、stream 补 include_usage、responses 专有字段丢弃);chatResponseToResponses(content/tool_calls/reasoning_content/usage 映射、finish_reason=length 判 incomplete);ResponsesSseTranslator 流式状态机(跨分包行缓冲、按 tool_calls index 归拢并行调用、reasoning_content 容错、usage 兜底、无 [DONE] 冲刷、坏行跳过、fail 补 response.failed)。18 个 golden 单测全过。

验证:tsc --noEmit 0 报错;npm test 110 用例全过(+16)。
cc 2 недель назад
Родитель
Сommit
b029cbbe24

+ 380 - 0
ai-electron/electron/service/codex/chatBridgeTranslate.test.ts

@@ -0,0 +1,380 @@
+import { describe, expect, it } from 'vitest';
+import {
+  chatResponseToResponses,
+  responsesRequestToChat,
+  ResponsesSseTranslator,
+  type BridgeSseEvent,
+  type ResponsesCreateParams,
+} from './chatBridgeTranslate';
+
+function sseLines(events: Array<Record<string, unknown>>): string {
+  return events.map((data) => `data: ${JSON.stringify(data)}\n\n`).join('');
+}
+
+function types(events: BridgeSseEvent[]): string[] {
+  return events.map((item) => item.event);
+}
+
+describe('responsesRequestToChat', () => {
+  it('instructions 转 developer message,input 字符串转 user message', () => {
+    const chat = responsesRequestToChat({ model: 'qwen', instructions: '你是助手', input: '你好' });
+    expect(chat.model).toBe('qwen');
+    expect(chat.messages).toEqual([
+      { role: 'developer', content: '你是助手' },
+      { role: 'user', content: '你好' },
+    ]);
+  });
+
+  it('message item:字符串 content 直传;easy-input-message(无 type)按 message 处理', () => {
+    const chat = responsesRequestToChat({
+      input: [
+        { type: 'message', role: 'user', content: '直接字符串' },
+        { role: 'assistant', content: '没有 type 字段' },
+      ],
+    });
+    expect(chat.messages).toEqual([
+      { role: 'user', content: '直接字符串' },
+      { role: 'assistant', content: '没有 type 字段' },
+    ]);
+  });
+
+  it('content parts:input_text 合并;单文本压成 string;input_file 丢弃并留诊断', () => {
+    const dropped: string[] = [];
+    const chat = responsesRequestToChat(
+      {
+        input: [
+          {
+            type: 'message',
+            role: 'user',
+            content: [
+              { type: 'input_text', text: '第一段' },
+              { type: 'input_file', file_url: 'file:///x.pdf' },
+              { type: 'input_text', text: '第二段' },
+            ],
+          },
+          {
+            type: 'message',
+            role: 'user',
+            content: [{ type: 'input_text', text: '唯一文本' }],
+          },
+        ],
+      },
+      (line) => dropped.push(line),
+    );
+    expect(chat.messages[0]).toEqual({
+      role: 'user',
+      content: [
+        { type: 'text', text: '第一段' },
+        { type: 'text', text: '第二段' },
+      ],
+    });
+    expect(chat.messages[1]).toEqual({ role: 'user', content: '唯一文本' });
+    expect(dropped.some((line) => line.includes('input_file'))).toBe(true);
+  });
+
+  it('function_call / function_call_output / reasoning 的映射与丢弃', () => {
+    const dropped: string[] = [];
+    const chat = responsesRequestToChat(
+      {
+        input: [
+          { type: 'reasoning', summary: [] },
+          { type: 'function_call', call_id: 'call_1', name: 'exec', arguments: '{"cmd":"ls"}' },
+          { type: 'function_call_output', call_id: 'call_1', output: 'ok' },
+        ],
+      },
+      (line) => dropped.push(line),
+    );
+    expect(chat.messages).toEqual([
+      {
+        role: 'assistant',
+        content: null,
+        tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'exec', arguments: '{"cmd":"ls"}' } }],
+      },
+      { role: 'tool', tool_call_id: 'call_1', content: 'ok' },
+    ]);
+    expect(dropped.some((line) => line.includes('reasoning'))).toBe(true);
+  });
+
+  it('tools 扁平转嵌套;tool_choice 各形态;max_output_tokens 转 max_tokens', () => {
+    const chat = responsesRequestToChat({
+      input: 'x',
+      tools: [
+        { type: 'function', name: 'exec', description: '执行', parameters: { type: 'object' } },
+        { type: 'web_search' },
+      ],
+      tool_choice: { type: 'function', name: 'exec' },
+      max_output_tokens: 1024,
+      temperature: 0.3,
+      top_p: 0.9,
+      parallel_tool_calls: false,
+    });
+    expect(chat.tools).toEqual([
+      { type: 'function', function: { name: 'exec', description: '执行', parameters: { type: 'object' } } },
+    ]);
+    expect(chat.tool_choice).toEqual({ type: 'function', function: { name: 'exec' } });
+    expect(chat.max_tokens).toBe(1024);
+    expect(chat.temperature).toBe(0.3);
+    expect(chat.top_p).toBe(0.9);
+    expect(chat.parallel_tool_calls).toBe(false);
+  });
+
+  it('tool_choice 字符串直传;无法识别的形态丢弃', () => {
+    expect(responsesRequestToChat({ input: 'x', tool_choice: 'required' }).tool_choice).toBe('required');
+    expect(
+      responsesRequestToChat({ input: 'x', tool_choice: { type: 'allowed_tools', tools: [] } }).tool_choice,
+    ).toBeUndefined();
+  });
+
+  it('stream 时补 stream_options.include_usage;responses 专有字段不外泄', () => {
+    const chat = responsesRequestToChat({
+      input: 'x',
+      stream: true,
+      previous_response_id: 'resp_x',
+      store: false,
+      metadata: { a: 1 },
+      reasoning: { effort: 'high' },
+      include: ['reasoning.encrypted_content'],
+      truncation: 'auto',
+    });
+    expect(chat.stream).toBe(true);
+    expect(chat.stream_options).toEqual({ include_usage: true });
+    const raw = JSON.stringify(chat);
+    // 注意要用带冒号的键名匹配:裸 'include' 会误伤 stream_options 的 include_usage
+    for (const key of ['previous_response_id', 'store', 'metadata', 'reasoning', 'include', 'truncation']) {
+      expect(raw).not.toContain(`"${key}":`);
+    }
+  });
+});
+
+describe('chatResponseToResponses', () => {
+  const req: ResponsesCreateParams = { model: 'qwen', input: 'x' };
+
+  it('content / tool_calls / reasoning_content / usage 全量映射', () => {
+    const response = chatResponseToResponses(
+      {
+        id: 'chatcmpl-1',
+        created: 1700000000,
+        model: 'qwen',
+        choices: [
+          {
+            finish_reason: 'tool_calls',
+            message: {
+              reasoning_content: '想一想',
+              content: '好的',
+              tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'exec', arguments: '{}' } }],
+            },
+          },
+        ],
+        usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
+      },
+      req,
+    );
+    expect(response).toMatchObject({
+      id: 'chatcmpl-1',
+      object: 'response',
+      created_at: 1700000000,
+      model: 'qwen',
+      status: 'completed',
+      usage: { input_tokens: 10, output_tokens: 20, total_tokens: 30 },
+    });
+    const output = response.output as Array<Record<string, unknown>>;
+    expect(output.map((item) => item.type)).toEqual(['reasoning', 'message', 'function_call']);
+    expect(output[1]).toMatchObject({
+      role: 'assistant',
+      status: 'completed',
+      content: [{ type: 'output_text', text: '好的' }],
+    });
+    expect(output[2]).toMatchObject({ call_id: 'call_1', name: 'exec', arguments: '{}' });
+  });
+
+  it('finish_reason=length 判 incomplete;无 usage 填全零', () => {
+    const response = chatResponseToResponses(
+      { choices: [{ finish_reason: 'length', message: { content: '截断' } }] },
+      req,
+    );
+    expect(response.status).toBe('incomplete');
+    expect(response.incomplete_details).toEqual({ reason: 'max_output_tokens' });
+    expect(response.usage).toEqual({ input_tokens: 0, output_tokens: 0, total_tokens: 0 });
+  });
+});
+
+describe('ResponsesSseTranslator', () => {
+  const req: ResponsesCreateParams = { model: 'qwen', input: 'x', stream: true };
+
+  it('纯文本流:完整 golden 事件序列', () => {
+    const translator = new ResponsesSseTranslator(req);
+    const events = translator.push(
+      sseLines([
+        { choices: [{ delta: { role: 'assistant', content: '' }, finish_reason: null }] },
+        { choices: [{ delta: { content: '你好' }, finish_reason: null }] },
+        { choices: [{ delta: { content: ',世界' }, finish_reason: null }] },
+        { choices: [{ delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 } },
+      ]) + 'data: [DONE]\n\n',
+    );
+    expect(types(events)).toEqual([
+      'response.created',
+      'response.in_progress',
+      'response.output_item.added',
+      'response.content_part.added',
+      'response.output_text.delta',
+      'response.output_text.delta',
+      'response.output_text.done',
+      'response.content_part.done',
+      'response.output_item.done',
+      'response.completed',
+    ]);
+    const completed = events.at(-1)?.data.response as Record<string, unknown>;
+    expect(completed.status).toBe('completed');
+    expect(completed.usage).toEqual({ input_tokens: 3, output_tokens: 2, total_tokens: 5 });
+    const output = completed.output as Array<Record<string, unknown>>;
+    expect(output).toHaveLength(1);
+    expect((output[0].content as Array<{ text: string }>)[0].text).toBe('你好,世界');
+  });
+
+  it('data: 行跨分包拼接', () => {
+    const translator = new ResponsesSseTranslator(req);
+    const chunk = `data: ${JSON.stringify({ choices: [{ delta: { content: '整段' } }] })}`;
+    const half = Math.floor(chunk.length / 2);
+    const first = translator.push(chunk.slice(0, half));
+    const second = translator.push(chunk.slice(half) + '\n\ndata: [DONE]\n\n');
+    expect(types([...first, ...second])).toEqual([
+      'response.created',
+      'response.in_progress',
+      'response.output_item.added',
+      'response.content_part.added',
+      'response.output_text.delta',
+      'response.output_text.done',
+      'response.content_part.done',
+      'response.output_item.done',
+      'response.completed',
+    ]);
+  });
+
+  it('两个并行 tool_calls 按 index 交错也能分别归拢', () => {
+    const translator = new ResponsesSseTranslator(req);
+    const events = translator.push(
+      sseLines([
+        {
+          choices: [
+            {
+              delta: {
+                tool_calls: [
+                  { index: 0, id: 'call_a', function: { name: 'exec', arguments: '{"a":' } },
+                  { index: 1, id: 'call_b', function: { name: 'read', arguments: '{"b":' } },
+                ],
+              },
+            },
+          ],
+        },
+        {
+          choices: [
+            {
+              delta: {
+                tool_calls: [
+                  { index: 1, function: { arguments: '1}' } },
+                  { index: 0, function: { arguments: '2}' } },
+                ],
+              },
+              finish_reason: 'tool_calls',
+            },
+          ],
+        },
+      ]) + 'data: [DONE]\n\n',
+    );
+    expect(types(events)).toEqual([
+      'response.created',
+      'response.in_progress',
+      'response.output_item.added',
+      'response.function_call_arguments.delta',
+      'response.output_item.added',
+      'response.function_call_arguments.delta',
+      'response.function_call_arguments.delta',
+      'response.function_call_arguments.delta',
+      'response.function_call_arguments.done',
+      'response.output_item.done',
+      'response.function_call_arguments.done',
+      'response.output_item.done',
+      'response.completed',
+    ]);
+    const completed = events.at(-1)?.data.response as Record<string, unknown>;
+    const output = completed.output as Array<Record<string, unknown>>;
+    expect(output.map((item) => [item.call_id, item.name, item.arguments])).toEqual([
+      ['call_a', 'exec', '{"a":2}'],
+      ['call_b', 'read', '{"b":1}'],
+    ]);
+  });
+
+  it('reasoning_content 流转 reasoning item,正文开始前闭合', () => {
+    const translator = new ResponsesSseTranslator(req);
+    const events = translator.push(
+      sseLines([
+        { choices: [{ delta: { reasoning_content: '先想' } }] },
+        { choices: [{ delta: { reasoning_content: '一下' } }] },
+        { choices: [{ delta: { content: '结论' } }] },
+        { choices: [{ delta: {}, finish_reason: 'stop' }] },
+      ]) + 'data: [DONE]\n\n',
+    );
+    expect(types(events)).toEqual([
+      'response.created',
+      'response.in_progress',
+      'response.output_item.added',
+      'response.reasoning_summary_part.added',
+      'response.reasoning_summary_text.delta',
+      'response.reasoning_summary_text.delta',
+      'response.reasoning_summary_text.done',
+      'response.reasoning_summary_part.done',
+      'response.output_item.done',
+      'response.output_item.added',
+      'response.content_part.added',
+      'response.output_text.delta',
+      'response.output_text.done',
+      'response.content_part.done',
+      'response.output_item.done',
+      'response.completed',
+    ]);
+    const completed = events.at(-1)?.data.response as Record<string, unknown>;
+    const output = completed.output as Array<Record<string, unknown>>;
+    expect(output[0]).toMatchObject({ type: 'reasoning', summary: [{ type: 'summary_text', text: '先想一下' }] });
+  });
+
+  it('上游没发 [DONE]:finish() 冲刷闭合;无 usage 填全零', () => {
+    const translator = new ResponsesSseTranslator(req);
+    translator.push(sseLines([{ choices: [{ delta: { content: '半截' } }] }]));
+    const events = translator.finish();
+    expect(types(events)).toEqual([
+      'response.output_text.done',
+      'response.content_part.done',
+      'response.output_item.done',
+      'response.completed',
+    ]);
+    const completed = events.at(-1)?.data.response as Record<string, unknown>;
+    expect(completed.usage).toEqual({ input_tokens: 0, output_tokens: 0, total_tokens: 0 });
+    // 再次 finish / push 不再重复发 completed
+    expect(translator.finish()).toEqual([]);
+  });
+
+  it('坏 JSON 行跳过不杀流;usage-only chunk 不产事件', () => {
+    const dropped: string[] = [];
+    const translator = new ResponsesSseTranslator(req, (line) => dropped.push(line));
+    const events = translator.push(
+      'data: {bad json\n\n' +
+        sseLines([{ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }]) +
+        sseLines([{ choices: [{ delta: { content: '好' }, finish_reason: 'stop' }] }]) +
+        'data: [DONE]\n\n',
+    );
+    expect(dropped.some((line) => line.includes('无法解析'))).toBe(true);
+    const completed = events.at(-1)?.data.response as Record<string, unknown>;
+    expect(completed.usage).toEqual({ input_tokens: 1, output_tokens: 1, total_tokens: 2 });
+  });
+
+  it('fail() 补 response.failed 并带错误信息', () => {
+    const translator = new ResponsesSseTranslator(req);
+    translator.push(sseLines([{ choices: [{ delta: { content: '写了一半' } }] }]));
+    const events = translator.fail('上游连接中断');
+    expect(types(events)).toEqual(['response.failed']);
+    const response = events[0].data.response as Record<string, unknown>;
+    expect(response.status).toBe('failed');
+    expect(response.error).toMatchObject({ code: 'upstream_error', message: '上游连接中断' });
+    expect(translator.finish()).toEqual([]);
+  });
+});

+ 714 - 0
ai-electron/electron/service/codex/chatBridgeTranslate.ts

@@ -0,0 +1,714 @@
+/**
+ * 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 })];
+  }
+
+  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);
+  }
+}