mirror of
https://github.com/Heretek-AI/opencode-4-openclaude.git
synced 2026-11-07 10:56:00 -10:00
382 lines
11 KiB
JavaScript
382 lines
11 KiB
JavaScript
/**
|
|
* Protocol Translation Layer
|
|
*
|
|
* OpenCode Go hosts models across two primary OpenAI API protocols:
|
|
* 1. Standard Chat Completions (/chat/completions): space-bunny, deepseek-v4-flash, glm-5.3, etc.
|
|
* 2. Responses API (/responses): muse-spark-1.3-contributor, gpt-5.6-luna, gpt-5.*, grok-*
|
|
*
|
|
* OpenClaude sends standard /v1/chat/completions.
|
|
* This module transparently adapts requests and streaming responses for models requiring /responses.
|
|
*/
|
|
|
|
// Models that strictly mandate the OpenAI Responses API protocol (/responses)
|
|
const RESPONSES_API_MODELS = new Set([
|
|
"muse-spark-1.3-contributor",
|
|
"gpt-5.6-luna",
|
|
"gpt-5.6-sol",
|
|
"gpt-5.6-terra",
|
|
"gpt-5.5",
|
|
"gpt-5.5-pro",
|
|
"gpt-5.4",
|
|
"gpt-5.4-pro",
|
|
"gpt-5.4-mini",
|
|
"gpt-5.4-nano",
|
|
"gpt-5.3-codex",
|
|
"gpt-5.3-codex-spark",
|
|
"gpt-5.2",
|
|
"gpt-5.2-codex",
|
|
"gpt-5.1",
|
|
"gpt-5.1-codex",
|
|
"gpt-5.1-codex-max",
|
|
"gpt-5.1-codex-mini",
|
|
"gpt-5",
|
|
"gpt-5-codex",
|
|
"gpt-5-nano",
|
|
"grok-4.5",
|
|
"grok-build-0.1",
|
|
]);
|
|
|
|
/**
|
|
* Checks whether a given model requires the /responses endpoint.
|
|
*/
|
|
export function requiresResponsesApi(modelName) {
|
|
if (!modelName) return false;
|
|
const normalized = modelName.toLowerCase().replace(/^(opencode-unlocked-|opencode-go-)/, "");
|
|
if (RESPONSES_API_MODELS.has(normalized)) return true;
|
|
// Prefix matching for gpt-5.* family if newer variants appear
|
|
if (normalized.startsWith("gpt-5") || normalized.startsWith("muse-spark")) return true;
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Translates an OpenAI Chat Completions request body into an OpenAI Responses API body.
|
|
*/
|
|
export function chatCompletionsToResponsesBody(body) {
|
|
const { messages, input, model, stream, temperature, max_tokens, max_output_tokens, tools, tool_choice, stop } = body;
|
|
|
|
const rawMessages = Array.isArray(input) ? input : Array.isArray(messages) ? messages : [];
|
|
|
|
// Map messages to Responses API input format
|
|
const formattedInput = rawMessages.map((msg) => {
|
|
if (!msg || typeof msg !== "object") return msg;
|
|
|
|
// Direct Responses API items pass through
|
|
if (!msg.role && msg.type) return msg;
|
|
|
|
// Standard Chat Completion messages
|
|
const role = msg.role;
|
|
const content = msg.content;
|
|
|
|
if (role === "system" || role === "developer") {
|
|
return {
|
|
role: "system",
|
|
content: typeof content === "string" ? content : (Array.isArray(content) ? content.map(c => c.text || "").join("") : "")
|
|
};
|
|
}
|
|
|
|
if (role === "user") {
|
|
if (typeof content === "string") {
|
|
return { role: "user", content };
|
|
}
|
|
if (Array.isArray(content)) {
|
|
return {
|
|
role: "user",
|
|
content: content.map((part) => {
|
|
if (part.type === "text" || part.type === "input_text") {
|
|
return { type: "text", text: part.text || "" };
|
|
}
|
|
if (part.type === "image_url") {
|
|
return { type: "image_url", image_url: part.image_url };
|
|
}
|
|
return part;
|
|
})
|
|
};
|
|
}
|
|
return { role: "user", content: String(content || "") };
|
|
}
|
|
|
|
if (role === "assistant") {
|
|
const out = { role: "assistant" };
|
|
if (typeof content === "string") out.content = content;
|
|
if (Array.isArray(msg.tool_calls)) out.tool_calls = msg.tool_calls;
|
|
return out;
|
|
}
|
|
|
|
if (role === "tool") {
|
|
return {
|
|
role: "tool",
|
|
tool_call_id: msg.tool_call_id,
|
|
content: typeof content === "string" ? content : JSON.stringify(content || "")
|
|
};
|
|
}
|
|
|
|
return msg;
|
|
});
|
|
|
|
const responsesPayload = {
|
|
model: model.replace(/^(opencode-unlocked-|opencode-go-)/, ""),
|
|
input: formattedInput,
|
|
stream: !!stream,
|
|
};
|
|
|
|
if (typeof temperature === "number") responsesPayload.temperature = temperature;
|
|
if (typeof max_output_tokens === "number") responsesPayload.max_output_tokens = max_output_tokens;
|
|
else if (typeof max_tokens === "number") responsesPayload.max_output_tokens = max_tokens;
|
|
|
|
// Translate reasoning_effort (OpenAI Chat Completions) to reasoning: { effort } (Responses API)
|
|
const incomingEffort = body.reasoning_effort || (typeof body.reasoning === "object" ? body.reasoning?.effort : undefined);
|
|
if (incomingEffort && incomingEffort !== "auto" && incomingEffort !== "none") {
|
|
responsesPayload.reasoning = {
|
|
effort: incomingEffort
|
|
};
|
|
}
|
|
|
|
if (Array.isArray(tools)) {
|
|
responsesPayload.tools = tools.map((t) => {
|
|
if (t.type === "function" && t.function) {
|
|
return {
|
|
type: "function",
|
|
name: t.function.name,
|
|
description: t.function.description,
|
|
parameters: t.function.parameters,
|
|
...(t.function.strict !== undefined ? { strict: t.function.strict } : {})
|
|
};
|
|
}
|
|
return t;
|
|
});
|
|
}
|
|
|
|
if (tool_choice) {
|
|
if (tool_choice === "auto" || tool_choice === "required" || tool_choice === "none") {
|
|
responsesPayload.tool_choice = tool_choice;
|
|
} else if (typeof tool_choice === "object" && tool_choice.type === "function" && tool_choice.function?.name) {
|
|
responsesPayload.tool_choice = { type: "function", name: tool_choice.function.name };
|
|
} else {
|
|
responsesPayload.tool_choice = tool_choice;
|
|
}
|
|
}
|
|
|
|
if (stop) responsesPayload.stop = stop;
|
|
|
|
return responsesPayload;
|
|
}
|
|
|
|
/**
|
|
* Translates a non-streaming Responses API JSON response to an OpenAI Chat Completions response.
|
|
*/
|
|
export function responsesToChatCompletionsResponse(responsesData, requestedModel) {
|
|
if (!responsesData || typeof responsesData !== "object") return responsesData;
|
|
|
|
const id = responsesData.id?.replace(/^resp_/, "chatcmpl_") || `chatcmpl_${Math.random().toString(36).slice(2)}`;
|
|
const model = responsesData.model || requestedModel;
|
|
|
|
let textContent = "";
|
|
const toolCalls = [];
|
|
|
|
const output = Array.isArray(responsesData.output) ? responsesData.output : [];
|
|
for (const item of output) {
|
|
if (item.type === "message") {
|
|
if (Array.isArray(item.content)) {
|
|
for (const part of item.content) {
|
|
if ((part.type === "output_text" || part.type === "text") && typeof part.text === "string") {
|
|
textContent += part.text;
|
|
}
|
|
}
|
|
} else if (typeof item.content === "string") {
|
|
textContent += item.content;
|
|
}
|
|
} else if (item.type === "function_call") {
|
|
toolCalls.push({
|
|
id: item.call_id || item.id,
|
|
type: "function",
|
|
function: {
|
|
name: item.name,
|
|
arguments: typeof item.arguments === "string" ? item.arguments : JSON.stringify(item.arguments || {})
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
const stopReason = responsesData.stop_reason;
|
|
let finishReason = "stop";
|
|
if (stopReason === "tool_call" || stopReason === "tool_calls" || toolCalls.length > 0) finishReason = "tool_calls";
|
|
else if (stopReason === "max_output_tokens" || stopReason === "length") finishReason = "length";
|
|
else if (stopReason === "content_filter") finishReason = "content_filter";
|
|
|
|
const message = {
|
|
role: "assistant",
|
|
content: textContent || (toolCalls.length ? null : "")
|
|
};
|
|
if (toolCalls.length > 0) {
|
|
message.tool_calls = toolCalls;
|
|
}
|
|
|
|
const usage = responsesData.usage ? {
|
|
prompt_tokens: responsesData.usage.input_tokens || 0,
|
|
completion_tokens: responsesData.usage.output_tokens || 0,
|
|
total_tokens: (responsesData.usage.input_tokens || 0) + (responsesData.usage.output_tokens || 0),
|
|
...(responsesData.usage.input_tokens_details?.cached_tokens ? {
|
|
prompt_tokens_details: { cached_tokens: responsesData.usage.input_tokens_details.cached_tokens }
|
|
} : {})
|
|
} : undefined;
|
|
|
|
return {
|
|
id,
|
|
object: "chat.completion",
|
|
created: Math.floor(Date.now() / 1000),
|
|
model,
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
message,
|
|
finish_reason: finishReason
|
|
}
|
|
],
|
|
...(usage ? { usage } : {})
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Transforms an SSE event line or block from Responses API format
|
|
* into OpenAI chat.completion.chunk SSE format.
|
|
*/
|
|
export function translateResponsesEventToOpenAiChunk(eventString, state = { id: "", model: "" }) {
|
|
if (!eventString || typeof eventString !== "string") return null;
|
|
|
|
const lines = eventString.split("\n");
|
|
let eventType = "";
|
|
let dataStr = "";
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith("event:")) {
|
|
eventType = line.slice(6).trim();
|
|
} else if (line.startsWith("data:")) {
|
|
dataStr = line.slice(5).trim();
|
|
}
|
|
}
|
|
|
|
if (!dataStr) return null;
|
|
if (dataStr === "[DONE]") return "data: [DONE]\n\n";
|
|
|
|
let data;
|
|
try {
|
|
data = JSON.parse(dataStr);
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
const respObj = data.response || {};
|
|
const id = respObj.id || data.id || state.id || `chatcmpl_${Math.random().toString(36).slice(2)}`;
|
|
const model = respObj.model || data.model || state.model || "opencode-unlocked";
|
|
|
|
state.id = id;
|
|
state.model = model;
|
|
|
|
const baseChunk = {
|
|
id,
|
|
object: "chat.completion.chunk",
|
|
created: Math.floor(Date.now() / 1000),
|
|
model,
|
|
choices: []
|
|
};
|
|
|
|
// Text Delta
|
|
if (
|
|
eventType === "response.output_text.delta" ||
|
|
eventType === "response.text.delta" ||
|
|
data.type === "response.output_text.delta"
|
|
) {
|
|
const text = data.delta ?? data.text ?? data.output_text_delta ?? "";
|
|
if (text) {
|
|
baseChunk.choices.push({
|
|
index: 0,
|
|
delta: { content: text },
|
|
finish_reason: null
|
|
});
|
|
return `data: ${JSON.stringify(baseChunk)}\n\n`;
|
|
}
|
|
}
|
|
|
|
// Function Call Added
|
|
if (
|
|
(eventType === "response.output_item.added" || data.type === "response.output_item.added") &&
|
|
data.item?.type === "function_call"
|
|
) {
|
|
baseChunk.choices.push({
|
|
index: 0,
|
|
delta: {
|
|
tool_calls: [
|
|
{
|
|
index: 0,
|
|
id: data.item.id || data.item.call_id,
|
|
type: "function",
|
|
function: {
|
|
name: data.item.name || "",
|
|
arguments: ""
|
|
}
|
|
}
|
|
]
|
|
},
|
|
finish_reason: null
|
|
});
|
|
return `data: ${JSON.stringify(baseChunk)}\n\n`;
|
|
}
|
|
|
|
// Function Call Arguments Delta
|
|
if (
|
|
eventType === "response.function_call_arguments.delta" ||
|
|
data.type === "response.function_call_arguments.delta"
|
|
) {
|
|
const argsDelta = data.delta ?? data.arguments_delta ?? "";
|
|
if (argsDelta) {
|
|
baseChunk.choices.push({
|
|
index: 0,
|
|
delta: {
|
|
tool_calls: [
|
|
{
|
|
index: 0,
|
|
function: {
|
|
arguments: argsDelta
|
|
}
|
|
}
|
|
]
|
|
},
|
|
finish_reason: null
|
|
});
|
|
return `data: ${JSON.stringify(baseChunk)}\n\n`;
|
|
}
|
|
}
|
|
|
|
// Response Completed
|
|
if (eventType === "response.completed" || data.type === "response.completed") {
|
|
const sr = respObj.stop_reason ?? data.stop_reason;
|
|
let fr = "stop";
|
|
if (sr === "tool_call" || sr === "tool_calls") fr = "tool_calls";
|
|
else if (sr === "max_output_tokens" || sr === "length") fr = "length";
|
|
else if (sr === "content_filter") fr = "content_filter";
|
|
|
|
baseChunk.choices.push({
|
|
index: 0,
|
|
delta: {},
|
|
finish_reason: fr
|
|
});
|
|
|
|
const u = respObj.usage ?? data.response?.usage ?? data.usage;
|
|
if (u) {
|
|
baseChunk.usage = {
|
|
prompt_tokens: u.input_tokens || 0,
|
|
completion_tokens: u.output_tokens || 0,
|
|
total_tokens: (u.input_tokens || 0) + (u.output_tokens || 0),
|
|
...(u.input_tokens_details?.cached_tokens ? {
|
|
prompt_tokens_details: { cached_tokens: u.input_tokens_details.cached_tokens }
|
|
} : {})
|
|
};
|
|
state.usage = baseChunk.usage;
|
|
}
|
|
|
|
return `data: ${JSON.stringify(baseChunk)}\n\n`;
|
|
}
|
|
|
|
return null;
|
|
}
|