feat: add desktop app hot update channel
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
// 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 };
|
||||
Reference in New Issue
Block a user