translate.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637
  1. /**
  2. * Responses ⇄ Chat Completions 翻译层(纯函数 + SSE 状态机,零 IO)。
  3. *
  4. * 规格依据:codex rust-v0.155.1 的线协议(codex-api/src/common.rs、
  5. * codex-api/src/sse/responses.rs、protocol/src/models.rs):
  6. * - 请求侧白名单式构建:store/include/reasoning/prompt_cache_key/text/client_metadata
  7. * 等 Chat 上游不认识的字段一律不发,宁可少发不发错。
  8. * - 响应侧只产 codex 实际消费的事件:output_item.done 是唯一驱动历史与工具执行的通道,
  9. * function_call 的 arguments 必须整体出现在 done(0.155.1 忽略 arguments.delta),
  10. * response.completed 的 response.id 必填,缺 usage 只影响统计不影响结束。
  11. * - tool_call_id 链路:Responses 的 call_id 原样作为 Chat 的 tool_calls[].id 与
  12. * role:"tool" 消息的 tool_call_id,codex 侧不透明字符串原样回传。
  13. */
  14. export type DiagnosticFn = (message: string) => void;
  15. /** codex POST /v1/responses 的请求体(只声明翻译用得到的字段,其余按白名单丢弃) */
  16. export interface ResponsesCreateParams {
  17. model?: unknown;
  18. instructions?: unknown;
  19. input?: unknown;
  20. tools?: unknown;
  21. tool_choice?: unknown;
  22. parallel_tool_calls?: unknown;
  23. max_output_tokens?: unknown;
  24. temperature?: unknown;
  25. top_p?: unknown;
  26. stream?: unknown;
  27. [key: string]: unknown;
  28. }
  29. export interface ChatToolCall {
  30. id: string;
  31. type: 'function';
  32. function: { name: string; arguments: string };
  33. }
  34. export interface ChatMessage {
  35. role: 'developer' | 'system' | 'user' | 'assistant' | 'tool';
  36. content?: unknown;
  37. tool_calls?: ChatToolCall[];
  38. tool_call_id?: string;
  39. name?: string;
  40. }
  41. export interface ChatCompletionRequest {
  42. model: string;
  43. messages: ChatMessage[];
  44. stream: boolean;
  45. stream_options?: { include_usage: boolean };
  46. tools?: Array<{ type: 'function'; function: { name: string; description?: string; parameters?: unknown } }>;
  47. tool_choice?: 'auto' | 'none' | 'required' | { type: 'function'; function: { name: string } };
  48. parallel_tool_calls?: boolean;
  49. max_tokens?: number;
  50. temperature?: number;
  51. top_p?: number;
  52. }
  53. interface ChatToolCallDelta {
  54. index?: number;
  55. id?: string;
  56. function?: { name?: string; arguments?: string };
  57. }
  58. interface ChatChunkChoice {
  59. delta?: { content?: string | null; reasoning_content?: string | null; tool_calls?: ChatToolCallDelta[] };
  60. finish_reason?: string | null;
  61. }
  62. interface ChatChunk {
  63. choices?: ChatChunkChoice[];
  64. usage?: {
  65. prompt_tokens?: number;
  66. completion_tokens?: number;
  67. total_tokens?: number;
  68. } | null;
  69. }
  70. /** 翻译过程中丢弃的非空内容计数,进诊断,绝不静默有损 */
  71. export interface TranslateDiagnostics {
  72. droppedInputItems: Map<string, number>;
  73. droppedContentParts: Map<string, number>;
  74. droppedTools: number;
  75. }
  76. function emptyDiagnostics(): TranslateDiagnostics {
  77. return { droppedInputItems: new Map(), droppedContentParts: new Map(), droppedTools: 0 };
  78. }
  79. function bump(map: Map<string, number>, key: string): void {
  80. map.set(key, (map.get(key) ?? 0) + 1);
  81. }
  82. function diagnosticsMessage(diagnostics: TranslateDiagnostics): string {
  83. const parts: string[] = [];
  84. for (const [key, count] of diagnostics.droppedInputItems) parts.push(`input.${key}×${count}`);
  85. for (const [key, count] of diagnostics.droppedContentParts) parts.push(`content.${key}×${count}`);
  86. if (diagnostics.droppedTools > 0) parts.push(`tools×${diagnostics.droppedTools}`);
  87. return parts.join(', ');
  88. }
  89. function asRecord(value: unknown): Record<string, unknown> | null {
  90. return value !== null && typeof value === 'object' && !Array.isArray(value)
  91. ? (value as Record<string, unknown>)
  92. : null;
  93. }
  94. function asString(value: unknown): string | null {
  95. return typeof value === 'string' ? value : null;
  96. }
  97. /** Responses 的 input_image → Chat image_url */
  98. function imageContentPart(part: Record<string, unknown>): ChatContentPart | null {
  99. const url = asString(part.image_url) ?? asString(asRecord(part.image_url)?.url);
  100. return url ? { type: 'image_url', image_url: { url } } : null;
  101. }
  102. type ChatContentPart = { type: 'text'; text: string } | { type: 'image_url'; image_url: { url: string } };
  103. /** Responses message 的 content 数组 → Chat content(文本/图片;单文本压成字符串兼容性最好) */
  104. function translateMessageContent(parts: unknown[], diagnostics: TranslateDiagnostics): string | ChatContentPart[] {
  105. const translated: ChatContentPart[] = [];
  106. for (const part of parts) {
  107. const record = asRecord(part);
  108. const type = asString(record?.type);
  109. if (type === 'input_text' || type === 'output_text') {
  110. const text = asString(record?.text) ?? '';
  111. translated.push({ type: 'text', text });
  112. } else if (type === 'input_image') {
  113. const image = imageContentPart(record ?? {});
  114. if (image) translated.push(image);
  115. else bump(diagnostics.droppedContentParts, 'input_image');
  116. } else if (type !== null) {
  117. bump(diagnostics.droppedContentParts, type ?? 'unknown');
  118. }
  119. }
  120. const textsOnly = translated.every((part) => part.type === 'text');
  121. if (textsOnly && translated.length <= 1) return (translated[0] as { text: string } | undefined)?.text ?? '';
  122. return translated;
  123. }
  124. /** function_call_output.output:字符串或结构化数组,折叠成 Chat tool 消息的文本 content */
  125. function translateOutputValue(value: unknown, diagnostics: TranslateDiagnostics): string {
  126. if (typeof value === 'string') return value;
  127. if (Array.isArray(value)) {
  128. const texts: string[] = [];
  129. for (const part of value) {
  130. const record = asRecord(part);
  131. const type = asString(record?.type);
  132. if (type === 'input_text' || type === 'output_text' || type === 'text') {
  133. texts.push(asString(record?.text) ?? '');
  134. } else {
  135. bump(diagnostics.droppedContentParts, type ?? 'unknown');
  136. }
  137. }
  138. return texts.join('\n');
  139. }
  140. if (value === null || value === undefined) return '';
  141. return String(value);
  142. }
  143. /**
  144. * Responses 请求体 → Chat Completions 请求体。
  145. * stream 透传;stream:true 时补 stream_options.include_usage(不支持的端点会忽略)。
  146. */
  147. export function responsesToChatRequest(
  148. req: ResponsesCreateParams,
  149. onDiagnostic?: DiagnosticFn,
  150. ): ChatCompletionRequest {
  151. const diagnostics = emptyDiagnostics();
  152. const messages: ChatMessage[] = [];
  153. const instructions = asString(req.instructions);
  154. if (instructions) messages.push({ role: 'developer', content: instructions });
  155. const input = req.input;
  156. if (typeof input === 'string') {
  157. messages.push({ role: 'user', content: input });
  158. } else if (Array.isArray(input)) {
  159. for (const raw of input) {
  160. const item = asRecord(raw);
  161. if (!item) continue;
  162. const type = asString(item.type);
  163. if (type === null || type === 'message') {
  164. const role = asString(item.role) ?? 'user';
  165. const content = Array.isArray(item.content)
  166. ? translateMessageContent(item.content, diagnostics)
  167. : asString(item.content) ?? '';
  168. messages.push({ role: role === 'developer' ? 'developer' : role as ChatMessage['role'], content });
  169. continue;
  170. }
  171. if (type === 'function_call' || type === 'custom_tool_call') {
  172. const callId = asString(item.call_id) ?? '';
  173. const name = asString(item.name) ?? '';
  174. const args = type === 'custom_tool_call'
  175. ? asString(item.input) ?? JSON.stringify(item.input ?? null)
  176. : typeof item.arguments === 'string' ? item.arguments : JSON.stringify(item.arguments ?? null);
  177. messages.push({
  178. role: 'assistant',
  179. content: null,
  180. tool_calls: [{ id: callId, type: 'function', function: { name, arguments: args } }],
  181. });
  182. continue;
  183. }
  184. if (type === 'function_call_output' || type === 'custom_tool_call_output') {
  185. messages.push({
  186. role: 'tool',
  187. tool_call_id: asString(item.call_id) ?? '',
  188. content: translateOutputValue(item.output, diagnostics),
  189. });
  190. continue;
  191. }
  192. // reasoning / local_shell_call / web_search_call / item_reference 等对 Chat 上游无意义
  193. bump(diagnostics.droppedInputItems, type ?? 'unknown');
  194. }
  195. }
  196. const chat: ChatCompletionRequest = {
  197. model: asString(req.model) ?? '',
  198. messages,
  199. stream: req.stream === true,
  200. };
  201. if (chat.stream) chat.stream_options = { include_usage: true };
  202. const tools = Array.isArray(req.tools) ? req.tools : [];
  203. const chatTools: ChatCompletionRequest['tools'] = [];
  204. for (const raw of tools) {
  205. const tool = asRecord(raw);
  206. if (asString(tool?.type) !== 'function') {
  207. diagnostics.droppedTools += 1;
  208. continue;
  209. }
  210. const description = asString(tool?.description);
  211. chatTools.push({
  212. type: 'function',
  213. function: {
  214. name: asString(tool?.name) ?? '',
  215. ...(description ? { description } : {}),
  216. ...(tool?.parameters !== undefined ? { parameters: tool?.parameters } : {}),
  217. },
  218. });
  219. }
  220. if (chatTools.length) chat.tools = chatTools;
  221. const toolChoice = req.tool_choice;
  222. if (toolChoice === 'auto' || toolChoice === 'none' || toolChoice === 'required') {
  223. chat.tool_choice = toolChoice;
  224. } else if (asString(asRecord(toolChoice)?.type) === 'function' && asRecord(toolChoice)?.name) {
  225. chat.tool_choice = { type: 'function', function: { name: asString(asRecord(toolChoice)?.name) ?? '' } };
  226. } else if (toolChoice !== undefined && toolChoice !== null) {
  227. bump(diagnostics.droppedInputItems, `tool_choice:${typeof toolChoice}`);
  228. }
  229. if (typeof req.parallel_tool_calls === 'boolean') chat.parallel_tool_calls = req.parallel_tool_calls;
  230. if (typeof req.max_output_tokens === 'number') chat.max_tokens = req.max_output_tokens;
  231. if (typeof req.temperature === 'number') chat.temperature = req.temperature;
  232. if (typeof req.top_p === 'number') chat.top_p = req.top_p;
  233. const summary = diagnosticsMessage(diagnostics);
  234. if (summary && onDiagnostic) onDiagnostic(`有损转换丢弃:${summary}`);
  235. return chat;
  236. }
  237. /** Chat 非流式响应 → Responses 形状(防御路径:codex 恒为 stream:true) */
  238. export function chatResponseToResponses(chat: Record<string, unknown>, responseId: string): Record<string, unknown> {
  239. const diagnostics = emptyDiagnostics();
  240. const choices = Array.isArray(chat.choices) ? chat.choices : [];
  241. const choice = asRecord(choices[0]) ?? {};
  242. const message = asRecord(choice.message) ?? {};
  243. const finishReason = asString(choice.finish_reason);
  244. const output: Array<Record<string, unknown>> = [];
  245. const reasoning = asString(message.reasoning_content) ?? asString(message.reasoning);
  246. if (reasoning) {
  247. output.push({
  248. type: 'reasoning',
  249. id: 'rs_1',
  250. summary: [{ type: 'summary_text', text: reasoning }],
  251. });
  252. }
  253. const content = asString(message.content);
  254. if (content) {
  255. output.push({
  256. type: 'message',
  257. id: 'msg_1',
  258. role: 'assistant',
  259. status: 'completed',
  260. content: [{ type: 'output_text', text: content }],
  261. });
  262. }
  263. const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
  264. let callIndex = 0;
  265. for (const raw of toolCalls) {
  266. const call = asRecord(raw);
  267. const fn = asRecord(call?.function);
  268. callIndex += 1;
  269. output.push({
  270. type: 'function_call',
  271. id: `fc_${callIndex}`,
  272. call_id: asString(call?.id) ?? `call_${callIndex}`,
  273. name: asString(fn?.name) ?? '',
  274. arguments: asString(fn?.arguments) ?? '',
  275. status: 'completed',
  276. });
  277. }
  278. if (!reasoning && !content && toolCalls.length === 0) {
  279. bump(diagnostics.droppedInputItems, 'empty_choice');
  280. }
  281. const usage = asRecord(chat.usage);
  282. const promptTokens = typeof usage?.prompt_tokens === 'number' ? usage.prompt_tokens : 0;
  283. const completionTokens = typeof usage?.completion_tokens === 'number' ? usage.completion_tokens : 0;
  284. const totalTokens = typeof usage?.total_tokens === 'number' ? usage.total_tokens : promptTokens + completionTokens;
  285. const incomplete = finishReason === 'length';
  286. return {
  287. id: responseId,
  288. object: 'response',
  289. status: incomplete ? 'incomplete' : 'completed',
  290. ...(incomplete ? { incomplete_details: { reason: 'max_output_tokens' } } : {}),
  291. output,
  292. usage: {
  293. input_tokens: promptTokens,
  294. input_tokens_details: { cached_tokens: 0 },
  295. output_tokens: completionTokens,
  296. output_tokens_details: { reasoning_tokens: 0 },
  297. total_tokens: totalTokens,
  298. },
  299. parallel_tool_calls: typeof chat.parallel_tool_calls === 'boolean' ? chat.parallel_tool_calls : undefined,
  300. };
  301. }
  302. export type BridgeSseFrame = string;
  303. interface OpenToolCall {
  304. callId: string | null;
  305. name: string;
  306. arguments: string;
  307. itemId: string;
  308. }
  309. function randomId(): string {
  310. return Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-6);
  311. }
  312. function sseFrame(type: string, data: Record<string, unknown>): string {
  313. return `event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`;
  314. }
  315. /**
  316. * 上游 Chat SSE → codex Responses SSE 的流式状态机。
  317. * push() 吃任意分包的文本(内部按行缓冲),finish()/fail() 收尾且幂等。
  318. * 一个响应内文本/推理/工具调用的 item 顺序:按上游到达顺序开闭,工具调用按 index 归拢后
  319. * 在 finish(或出现更大 index)时补发完整 done —— arguments 必须整体出现。
  320. */
  321. export class ChatSseTranslator {
  322. readonly #responseId: string;
  323. readonly #onDiagnostic?: DiagnosticFn;
  324. #buffer = '';
  325. #itemSeq = 0;
  326. #closed = false;
  327. #textState: { itemId: string; text: string } | null = null;
  328. #reasoningState: { itemId: string; text: string } | null = null;
  329. /** index → 归拢中的工具调用;出现更大 index 时先收口前面的 */
  330. #toolCalls = new Map<number, OpenToolCall>();
  331. #toolCallOrder: number[] = [];
  332. #toolCallsDone = false;
  333. #finishReason: string | null = null;
  334. #usage: { input_tokens: number; output_tokens: number; total_tokens: number } | null = null;
  335. #upstreamBytes = 0;
  336. #emittedEvents = 0;
  337. constructor(onDiagnostic?: DiagnosticFn) {
  338. this.#onDiagnostic = onDiagnostic;
  339. this.#responseId = `resp_${randomId()}`;
  340. }
  341. get responseId(): string {
  342. return this.#responseId;
  343. }
  344. get upstreamBytes(): number {
  345. return this.#upstreamBytes;
  346. }
  347. get emittedEvents(): number {
  348. return this.#emittedEvents;
  349. }
  350. #frame(type: string, data: Record<string, unknown>): string {
  351. this.#emittedEvents += 1;
  352. return sseFrame(type, data);
  353. }
  354. #nextItemId(prefix: string): string {
  355. this.#itemSeq += 1;
  356. return `${prefix}_${this.#itemSeq}`;
  357. }
  358. /** 先建 SSE:不等上游响应头就发 created + in_progress,防单槽端点慢 prefill 掐线 */
  359. begin(): string[] {
  360. if (this.#closed) return [];
  361. return [
  362. this.#frame('response.created', { response: { id: this.#responseId } }),
  363. this.#frame('response.in_progress', { response: { id: this.#responseId } }),
  364. ];
  365. }
  366. /** 关闭当前打开的文本/推理 item,产出 done 帧 */
  367. #closeText(): string[] {
  368. if (!this.#textState) return [];
  369. const { itemId, text } = this.#textState;
  370. this.#textState = null;
  371. return [
  372. this.#frame('response.output_item.done', {
  373. item: {
  374. type: 'message',
  375. id: itemId,
  376. role: 'assistant',
  377. status: 'completed',
  378. content: [{ type: 'output_text', text }],
  379. },
  380. }),
  381. ];
  382. }
  383. #closeReasoning(): string[] {
  384. if (!this.#reasoningState) return [];
  385. const { itemId, text } = this.#reasoningState;
  386. this.#reasoningState = null;
  387. return [
  388. this.#frame('response.output_item.done', {
  389. item: {
  390. type: 'reasoning',
  391. id: itemId,
  392. summary: [{ type: 'summary_text', text }],
  393. },
  394. }),
  395. ];
  396. }
  397. /** 收口所有归拢中的工具调用(按 index 序),产出完整 function_call done 帧 */
  398. #closeToolCalls(): string[] {
  399. if (this.#toolCallsDone) return [];
  400. this.#toolCallsDone = true;
  401. const frames: string[] = [];
  402. for (const index of this.#toolCallOrder) {
  403. const call = this.#toolCalls.get(index);
  404. if (!call) continue;
  405. frames.push(
  406. this.#frame('response.output_item.done', {
  407. item: {
  408. type: 'function_call',
  409. id: call.itemId,
  410. call_id: call.callId ?? `call_${index}`,
  411. name: call.name,
  412. arguments: call.arguments,
  413. status: 'completed',
  414. },
  415. }),
  416. );
  417. }
  418. return frames;
  419. }
  420. push(chunk: string): string[] {
  421. if (this.#closed) return [];
  422. this.#upstreamBytes += Buffer.byteLength(chunk);
  423. this.#buffer += chunk;
  424. const frames: string[] = [];
  425. let newlineIndex = this.#buffer.indexOf('\n');
  426. while (newlineIndex >= 0) {
  427. const line = this.#buffer.slice(0, newlineIndex).replace(/\r$/, '');
  428. this.#buffer = this.#buffer.slice(newlineIndex + 1);
  429. const payload = this.#dataPayload(line);
  430. if (payload) frames.push(...this.#handleData(payload));
  431. newlineIndex = this.#buffer.indexOf('\n');
  432. }
  433. return frames;
  434. }
  435. /** 提取 SSE data: 行的载荷;[DONE]/注释行/空行返回 null */
  436. #dataPayload(line: string): string | null {
  437. if (!line.startsWith('data:')) return null;
  438. const payload = line.slice(5).trim();
  439. if (!payload || payload === '[DONE]') return null;
  440. return payload;
  441. }
  442. #handleData(payload: string): string[] {
  443. let chunk: ChatChunk;
  444. try {
  445. chunk = JSON.parse(payload) as ChatChunk;
  446. } catch {
  447. // 坏 JSON 跳过不杀流
  448. this.#onDiagnostic?.('上游 SSE 出现无法解析的 JSON 分片,已跳过');
  449. return [];
  450. }
  451. const frames: string[] = [];
  452. if (chunk.usage && typeof chunk.usage === 'object') {
  453. const promptTokens = typeof chunk.usage.prompt_tokens === 'number' ? chunk.usage.prompt_tokens : 0;
  454. const completionTokens = typeof chunk.usage.completion_tokens === 'number' ? chunk.usage.completion_tokens : 0;
  455. const totalTokens = typeof chunk.usage.total_tokens === 'number'
  456. ? chunk.usage.total_tokens
  457. : promptTokens + completionTokens;
  458. this.#usage = { input_tokens: promptTokens, output_tokens: completionTokens, total_tokens: totalTokens };
  459. }
  460. const choice = chunk.choices?.[0];
  461. if (!choice) return frames;
  462. if (choice.finish_reason) this.#finishReason = choice.finish_reason;
  463. const delta = choice.delta;
  464. if (delta?.tool_calls?.length) {
  465. // 工具调用开跑:先收口打开的文本/推理 item
  466. frames.push(...this.#closeText(), ...this.#closeReasoning());
  467. for (const callDelta of delta.tool_calls) {
  468. const index = typeof callDelta.index === 'number' ? callDelta.index : 0;
  469. let call = this.#toolCalls.get(index);
  470. if (!call) {
  471. // 出现新 index:说明更小的 index 已收口完成
  472. frames.push(...this.#closeEarlierToolCalls(index));
  473. call = { callId: null, name: '', arguments: '', itemId: this.#nextItemId('fc') };
  474. this.#toolCalls.set(index, call);
  475. this.#toolCallOrder.push(index);
  476. }
  477. if (callDelta.id) call.callId = callDelta.id;
  478. if (callDelta.function?.name) call.name += callDelta.function.name;
  479. if (callDelta.function?.arguments) call.arguments += callDelta.function.arguments;
  480. }
  481. }
  482. if (typeof delta?.reasoning_content === 'string' && delta.reasoning_content.length > 0) {
  483. if (this.#textState) frames.push(...this.#closeText());
  484. if (!this.#reasoningState) {
  485. this.#reasoningState = { itemId: this.#nextItemId('rs'), text: '' };
  486. frames.push(
  487. this.#frame('response.output_item.added', {
  488. item: { type: 'reasoning', id: this.#reasoningState.itemId, summary: [] },
  489. }),
  490. );
  491. }
  492. this.#reasoningState.text += delta.reasoning_content;
  493. frames.push(
  494. this.#frame('response.reasoning_summary_text.delta', {
  495. delta: delta.reasoning_content,
  496. summary_index: 0,
  497. item_id: this.#reasoningState.itemId,
  498. }),
  499. );
  500. }
  501. if (typeof delta?.content === 'string' && delta.content.length > 0) {
  502. if (this.#reasoningState) frames.push(...this.#closeReasoning());
  503. if (!this.#textState) {
  504. this.#textState = { itemId: this.#nextItemId('msg'), text: '' };
  505. frames.push(
  506. this.#frame('response.output_item.added', {
  507. item: { type: 'message', id: this.#textState.itemId, role: 'assistant', content: [] },
  508. }),
  509. );
  510. }
  511. this.#textState.text += delta.content;
  512. frames.push(this.#frame('response.output_text.delta', { delta: delta.content, item_id: this.#textState.itemId }));
  513. }
  514. return frames;
  515. }
  516. /** 出现 index N 时收口所有 < N 的工具调用 */
  517. #closeEarlierToolCalls(index: number): string[] {
  518. const frames: string[] = [];
  519. for (const earlier of this.#toolCallOrder) {
  520. if (earlier >= index) break;
  521. const call = this.#toolCalls.get(earlier);
  522. if (!call) continue;
  523. frames.push(
  524. this.#frame('response.output_item.done', {
  525. item: {
  526. type: 'function_call',
  527. id: call.itemId,
  528. call_id: call.callId ?? `call_${earlier}`,
  529. name: call.name,
  530. arguments: call.arguments,
  531. status: 'completed',
  532. },
  533. }),
  534. );
  535. this.#toolCalls.delete(earlier);
  536. }
  537. return frames;
  538. }
  539. /** [DONE]/上游结束:收口全部 item 并补 response.completed。幂等 */
  540. finish(): string[] {
  541. if (this.#closed) return [];
  542. this.#closed = true;
  543. const frames: string[] = [
  544. ...this.#closeReasoning(),
  545. ...this.#closeText(),
  546. ...this.#closeToolCalls(),
  547. ];
  548. const incomplete = this.#finishReason === 'length';
  549. frames.push(
  550. this.#frame('response.completed', {
  551. response: {
  552. id: this.#responseId,
  553. status: incomplete ? 'incomplete' : 'completed',
  554. ...(incomplete ? { incomplete_details: { reason: 'max_output_tokens' } } : {}),
  555. ...(this.#usage ? { usage: {
  556. input_tokens: this.#usage.input_tokens,
  557. input_tokens_details: { cached_tokens: 0 },
  558. output_tokens: this.#usage.output_tokens,
  559. output_tokens_details: { reasoning_tokens: 0 },
  560. total_tokens: this.#usage.total_tokens,
  561. } } : {}),
  562. },
  563. }),
  564. );
  565. return frames;
  566. }
  567. /** 上游失败:补 response.failed 让 codex 走错误映射。幂等 */
  568. fail(message: string): string[] {
  569. if (this.#closed) return [];
  570. this.#closed = true;
  571. return [
  572. this.#frame('response.failed', {
  573. response: {
  574. id: this.#responseId,
  575. error: { code: 'upstream_error', message },
  576. },
  577. }),
  578. ];
  579. }
  580. }