355 lines
12 KiB
JavaScript
355 lines
12 KiB
JavaScript
// translate.js —— Responses API ⇄ Chat Completions 协议转换。
|
|
// 让只支持 chat/completions 的中转(DeepSeek/GLM/Kimi/通义等)也能给 Codex(用 responses)用。
|
|
// 参考实现:sybil-solutions/codex-shim、BerriAI/litellm。
|
|
const crypto = require("crypto");
|
|
|
|
function genId(prefix) {
|
|
return prefix + Date.now().toString(36) + crypto.randomBytes(4).toString("hex");
|
|
}
|
|
|
|
// content parts / string → 纯文本
|
|
function partsToText(content) {
|
|
if (typeof content === "string") return content;
|
|
if (Array.isArray(content)) {
|
|
return content
|
|
.map((p) => {
|
|
if (typeof p === "string") return p;
|
|
if (p && (p.type === "input_text" || p.type === "output_text" || p.type === "text")) return p.text || "";
|
|
return "";
|
|
})
|
|
.join("");
|
|
}
|
|
return "";
|
|
}
|
|
|
|
// Responses 的 tool(扁平)→ Chat Completions 的 tool(嵌套在 function 下)
|
|
function toChatTool(t) {
|
|
if (!t) return null;
|
|
if (t.type === "function" && t.function) return t; // 已是嵌套
|
|
if (t.type === "function" || t.name) {
|
|
return {
|
|
type: "function",
|
|
function: {
|
|
name: t.name,
|
|
description: t.description || "",
|
|
parameters: t.parameters || { type: "object", properties: {} },
|
|
},
|
|
};
|
|
}
|
|
return null; // 托管型工具(web_search 等)无 chat 等价,先跳过
|
|
}
|
|
function toChatToolChoice(tc) {
|
|
if (typeof tc === "string") return tc;
|
|
if (tc && tc.type === "function" && tc.name) return { type: "function", function: { name: tc.name } };
|
|
return tc;
|
|
}
|
|
|
|
// —— 请求:Responses → Chat Completions ——
|
|
function responsesToChat(req, overrideModel) {
|
|
const messages = [];
|
|
if (req.instructions) messages.push({ role: "system", content: req.instructions });
|
|
const pending = [];
|
|
const flush = () => {
|
|
if (pending.length) {
|
|
messages.push({ role: "assistant", content: null, tool_calls: pending.splice(0) });
|
|
}
|
|
};
|
|
const input = req.input;
|
|
if (typeof input === "string") {
|
|
messages.push({ role: "user", content: input });
|
|
} else if (Array.isArray(input)) {
|
|
for (const item of input) {
|
|
const t = item.type;
|
|
if (t === "message" || (item.role && !t)) {
|
|
flush();
|
|
const role = item.role === "developer" ? "system" : item.role;
|
|
messages.push({ role, content: partsToText(item.content) });
|
|
} else if (t === "function_call") {
|
|
const callId = item.call_id || item.id || "call_0";
|
|
pending.push({ id: callId, type: "function", function: { name: item.name || "", arguments: item.arguments || "" } });
|
|
} else if (t === "function_call_output") {
|
|
flush();
|
|
const out = typeof item.output === "string" ? item.output : JSON.stringify(item.output);
|
|
messages.push({ role: "tool", tool_call_id: item.call_id, content: out });
|
|
}
|
|
// reasoning item → 丢弃(chat 上游用不到)
|
|
}
|
|
}
|
|
flush();
|
|
|
|
const out = { model: overrideModel || req.model, messages, stream: req.stream !== false };
|
|
if (Array.isArray(req.tools)) {
|
|
const tools = req.tools.map(toChatTool).filter(Boolean);
|
|
if (tools.length) out.tools = tools;
|
|
}
|
|
if (req.tool_choice) out.tool_choice = toChatToolChoice(req.tool_choice);
|
|
if (req.max_output_tokens) out.max_tokens = req.max_output_tokens;
|
|
if (req.temperature != null) out.temperature = req.temperature;
|
|
if (req.top_p != null) out.top_p = req.top_p;
|
|
if (req.parallel_tool_calls != null) out.parallel_tool_calls = req.parallel_tool_calls;
|
|
const eff = req.reasoning && req.reasoning.effort;
|
|
if (eff) out.reasoning_effort = eff;
|
|
if (out.stream) out.stream_options = { include_usage: true };
|
|
return out;
|
|
}
|
|
|
|
function mapUsage(u) {
|
|
if (!u) return null;
|
|
return {
|
|
input_tokens: u.prompt_tokens || 0,
|
|
input_tokens_details: { cached_tokens: (u.prompt_tokens_details && u.prompt_tokens_details.cached_tokens) || 0 },
|
|
output_tokens: u.completion_tokens || 0,
|
|
output_tokens_details: { reasoning_tokens: 0 },
|
|
total_tokens: u.total_tokens || 0,
|
|
};
|
|
}
|
|
|
|
// —— 响应:Chat Completions 流 → Responses 流事件(有状态转换器)——
|
|
class ChatToResponses {
|
|
constructor(model) {
|
|
this.model = model;
|
|
this.responseId = genId("resp_");
|
|
this.msgId = genId("msg_");
|
|
this.seq = 0;
|
|
this.outIndex = 0;
|
|
this.msgOpen = false;
|
|
this.msgClosed = false;
|
|
this.msgText = "";
|
|
this.msgOutputIndex = null;
|
|
this.tools = {}; // index → {id,name,arguments,outputIndex,closed}
|
|
this.usage = null;
|
|
this.started = false;
|
|
// 推理模型(DeepSeek-R1/v4、GLM、Kimi)的思考内容
|
|
this.reasonOpen = false;
|
|
this.reasonClosed = false;
|
|
this.reasonText = "";
|
|
this.reasonId = null;
|
|
this.reasonOutputIndex = null;
|
|
}
|
|
openReason() {
|
|
if (this.reasonOpen) return "";
|
|
this.reasonOpen = true;
|
|
this.reasonId = genId("rs_");
|
|
this.reasonOutputIndex = this.outIndex++;
|
|
let s = this.ev("response.output_item.added", {
|
|
output_index: this.reasonOutputIndex,
|
|
item: { id: this.reasonId, type: "reasoning", summary: [] },
|
|
});
|
|
s += this.ev("response.reasoning_summary_part.added", {
|
|
item_id: this.reasonId,
|
|
output_index: this.reasonOutputIndex,
|
|
summary_index: 0,
|
|
part: { type: "summary_text", text: "" },
|
|
});
|
|
return s;
|
|
}
|
|
closeReason() {
|
|
if (!this.reasonOpen || this.reasonClosed) return "";
|
|
this.reasonClosed = true;
|
|
let s = this.ev("response.reasoning_summary_text.done", {
|
|
item_id: this.reasonId,
|
|
output_index: this.reasonOutputIndex,
|
|
summary_index: 0,
|
|
text: this.reasonText,
|
|
});
|
|
s += this.ev("response.reasoning_summary_part.done", {
|
|
item_id: this.reasonId,
|
|
output_index: this.reasonOutputIndex,
|
|
summary_index: 0,
|
|
part: { type: "summary_text", text: this.reasonText },
|
|
});
|
|
s += this.ev("response.output_item.done", {
|
|
output_index: this.reasonOutputIndex,
|
|
item: { id: this.reasonId, type: "reasoning", summary: [{ type: "summary_text", text: this.reasonText }] },
|
|
});
|
|
return s;
|
|
}
|
|
ev(type, obj) {
|
|
const payload = Object.assign({ type, sequence_number: this.seq++ }, obj);
|
|
return `event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`;
|
|
}
|
|
baseResponse(status, output) {
|
|
return {
|
|
id: this.responseId,
|
|
object: "response",
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
status,
|
|
error: null,
|
|
incomplete_details: null,
|
|
instructions: null,
|
|
model: this.model,
|
|
output,
|
|
parallel_tool_calls: true,
|
|
previous_response_id: null,
|
|
reasoning: { effort: null, summary: null },
|
|
store: false,
|
|
temperature: 1.0,
|
|
text: { format: { type: "text" } },
|
|
tool_choice: "auto",
|
|
tools: [],
|
|
top_p: 1.0,
|
|
truncation: "disabled",
|
|
usage: this.usage,
|
|
metadata: {},
|
|
};
|
|
}
|
|
start() {
|
|
const r = this.baseResponse("in_progress", []);
|
|
return this.ev("response.created", { response: r }) + this.ev("response.in_progress", { response: r });
|
|
}
|
|
openMsg() {
|
|
if (this.msgOpen) return "";
|
|
this.msgOpen = true;
|
|
this.msgOutputIndex = this.outIndex++;
|
|
let s = this.ev("response.output_item.added", {
|
|
output_index: this.msgOutputIndex,
|
|
item: { id: this.msgId, type: "message", status: "in_progress", role: "assistant", content: [] },
|
|
});
|
|
s += this.ev("response.content_part.added", {
|
|
item_id: this.msgId,
|
|
output_index: this.msgOutputIndex,
|
|
content_index: 0,
|
|
part: { type: "output_text", text: "", annotations: [] },
|
|
});
|
|
return s;
|
|
}
|
|
closeMsg() {
|
|
if (!this.msgOpen || this.msgClosed) return "";
|
|
this.msgClosed = true;
|
|
let s = this.ev("response.output_text.done", {
|
|
item_id: this.msgId,
|
|
output_index: this.msgOutputIndex,
|
|
content_index: 0,
|
|
text: this.msgText,
|
|
logprobs: [],
|
|
});
|
|
s += this.ev("response.content_part.done", {
|
|
item_id: this.msgId,
|
|
output_index: this.msgOutputIndex,
|
|
content_index: 0,
|
|
part: { type: "output_text", text: this.msgText, annotations: [] },
|
|
});
|
|
s += this.ev("response.output_item.done", {
|
|
output_index: this.msgOutputIndex,
|
|
item: {
|
|
id: this.msgId,
|
|
type: "message",
|
|
status: "completed",
|
|
role: "assistant",
|
|
content: [{ type: "output_text", text: this.msgText, annotations: [] }],
|
|
},
|
|
});
|
|
return s;
|
|
}
|
|
onToolDelta(tc) {
|
|
let out = "";
|
|
const index = tc.index != null ? tc.index : 0;
|
|
const fn = tc.function || {};
|
|
let st = this.tools[index];
|
|
if (!st) {
|
|
out += this.closeReason(); // 先关推理
|
|
out += this.closeMsg(); // 再关文本消息,才开工具项
|
|
const callId = tc.id || "call_" + index;
|
|
st = { id: callId, name: fn.name || "", arguments: "", outputIndex: this.outIndex++, closed: false };
|
|
this.tools[index] = st;
|
|
out += this.ev("response.output_item.added", {
|
|
output_index: st.outputIndex,
|
|
item: { id: st.id, type: "function_call", status: "in_progress", call_id: st.id, name: st.name, arguments: "" },
|
|
});
|
|
} else if (fn.name) {
|
|
st.name += fn.name;
|
|
}
|
|
if (fn.arguments) {
|
|
st.arguments += fn.arguments;
|
|
out += this.ev("response.function_call_arguments.delta", {
|
|
item_id: st.id,
|
|
output_index: st.outputIndex,
|
|
delta: fn.arguments,
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
closeTool(st) {
|
|
if (st.closed) return "";
|
|
st.closed = true;
|
|
let s = this.ev("response.function_call_arguments.done", {
|
|
item_id: st.id,
|
|
output_index: st.outputIndex,
|
|
arguments: st.arguments,
|
|
});
|
|
s += this.ev("response.output_item.done", {
|
|
output_index: st.outputIndex,
|
|
item: { id: st.id, type: "function_call", status: "completed", call_id: st.id, name: st.name, arguments: st.arguments },
|
|
});
|
|
return s;
|
|
}
|
|
// 喂一个 chat 流 chunk(已解析的 JSON),返回要写给 Codex 的 Responses 事件串
|
|
onChunk(obj) {
|
|
let out = "";
|
|
if (!this.started) {
|
|
out += this.start();
|
|
this.started = true;
|
|
}
|
|
if (obj.usage) this.usage = mapUsage(obj.usage);
|
|
const choice = obj.choices && obj.choices[0];
|
|
if (!choice) return out;
|
|
const delta = choice.delta || {};
|
|
// 推理内容(reasoning_content)→ 思考事件
|
|
const rc = delta.reasoning_content || delta.reasoning;
|
|
if (rc) {
|
|
out += this.openReason();
|
|
this.reasonText += rc;
|
|
out += this.ev("response.reasoning_summary_text.delta", {
|
|
item_id: this.reasonId,
|
|
output_index: this.reasonOutputIndex,
|
|
summary_index: 0,
|
|
delta: rc,
|
|
});
|
|
}
|
|
if (delta.content) {
|
|
out += this.closeReason(); // 有正文了,先收尾推理
|
|
out += this.openMsg();
|
|
this.msgText += delta.content;
|
|
out += this.ev("response.output_text.delta", {
|
|
item_id: this.msgId,
|
|
output_index: this.msgOutputIndex,
|
|
content_index: 0,
|
|
delta: delta.content,
|
|
logprobs: [],
|
|
});
|
|
}
|
|
if (Array.isArray(delta.tool_calls)) {
|
|
for (const tc of delta.tool_calls) out += this.onToolDelta(tc);
|
|
}
|
|
return out;
|
|
}
|
|
finish() {
|
|
let out = "";
|
|
if (!this.started) {
|
|
out += this.start();
|
|
this.started = true;
|
|
}
|
|
out += this.closeReason();
|
|
out += this.closeMsg();
|
|
const sts = Object.values(this.tools).sort((a, b) => a.outputIndex - b.outputIndex);
|
|
for (const st of sts) out += this.closeTool(st);
|
|
const output = [];
|
|
if (this.reasonOpen)
|
|
output.push({ id: this.reasonId, type: "reasoning", summary: [{ type: "summary_text", text: this.reasonText }] });
|
|
if (this.msgOpen)
|
|
output.push({
|
|
id: this.msgId,
|
|
type: "message",
|
|
status: "completed",
|
|
role: "assistant",
|
|
content: [{ type: "output_text", text: this.msgText, annotations: [] }],
|
|
});
|
|
for (const st of sts)
|
|
output.push({ id: st.id, type: "function_call", status: "completed", call_id: st.id, name: st.name, arguments: st.arguments });
|
|
out += this.ev("response.completed", { response: this.baseResponse("completed", output) });
|
|
return out;
|
|
}
|
|
}
|
|
|
|
module.exports = { responsesToChat, ChatToResponses };
|