mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
201 lines
8.3 KiB
JavaScript
201 lines
8.3 KiB
JavaScript
import { FORMATS } from "../../translator/formats.js";
|
|
import { needsTranslation } from "../../translator/index.js";
|
|
import { ollamaBodyToOpenAI } from "../../translator/response/ollama-to-openai.js";
|
|
import { addBufferToUsage, filterUsageForFormat } from "../../utils/usageTracking.js";
|
|
import { createErrorResult } from "../../utils/error.js";
|
|
import { HTTP_STATUS } from "../../config/runtimeConfig.js";
|
|
import { parseSSEToOpenAIResponse } from "./sseToJsonHandler.js";
|
|
import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, saveUsageStats } from "./requestDetail.js";
|
|
import { appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
|
|
|
|
/**
|
|
* Translate non-streaming response body from provider format → OpenAI format.
|
|
*/
|
|
export function translateNonStreamingResponse(responseBody, targetFormat, sourceFormat) {
|
|
if (targetFormat === sourceFormat || targetFormat === FORMATS.OPENAI) return responseBody;
|
|
|
|
// Gemini / Antigravity
|
|
if (targetFormat === FORMATS.GEMINI || targetFormat === FORMATS.ANTIGRAVITY || targetFormat === FORMATS.GEMINI_CLI) {
|
|
const response = responseBody.response || responseBody;
|
|
if (!response?.candidates?.[0]) return responseBody;
|
|
|
|
const candidate = response.candidates[0];
|
|
const content = candidate.content;
|
|
const usage = response.usageMetadata || responseBody.usageMetadata;
|
|
let textContent = "", reasoningContent = "";
|
|
const toolCalls = [];
|
|
|
|
if (content?.parts) {
|
|
for (const part of content.parts) {
|
|
if (part.thought === true && part.text) reasoningContent += part.text;
|
|
else if (part.text !== undefined) textContent += part.text;
|
|
if (part.functionCall) {
|
|
toolCalls.push({
|
|
id: `call_${part.functionCall.name}_${Date.now()}_${toolCalls.length}`,
|
|
type: "function",
|
|
function: { name: part.functionCall.name, arguments: JSON.stringify(part.functionCall.args || {}) }
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const message = { role: "assistant" };
|
|
if (textContent) message.content = textContent;
|
|
if (reasoningContent) message.reasoning_content = reasoningContent;
|
|
if (toolCalls.length > 0) message.tool_calls = toolCalls;
|
|
if (!message.content && !message.tool_calls) message.content = "";
|
|
|
|
let finishReason = (candidate.finishReason || "stop").toLowerCase();
|
|
if (finishReason === "stop" && toolCalls.length > 0) finishReason = "tool_calls";
|
|
|
|
const result = {
|
|
id: `chatcmpl-${response.responseId || Date.now()}`,
|
|
object: "chat.completion",
|
|
created: Math.floor(new Date(response.createTime || Date.now()).getTime() / 1000),
|
|
model: response.modelVersion || "gemini",
|
|
choices: [{ index: 0, message, finish_reason: finishReason }]
|
|
};
|
|
|
|
if (usage) {
|
|
result.usage = {
|
|
prompt_tokens: (usage.promptTokenCount || 0) + (usage.thoughtsTokenCount || 0),
|
|
completion_tokens: usage.candidatesTokenCount || 0,
|
|
total_tokens: usage.totalTokenCount || 0
|
|
};
|
|
if (usage.thoughtsTokenCount > 0) {
|
|
result.usage.completion_tokens_details = { reasoning_tokens: usage.thoughtsTokenCount };
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Claude
|
|
if (targetFormat === FORMATS.CLAUDE) {
|
|
if (!responseBody.content) return responseBody;
|
|
|
|
let textContent = "", thinkingContent = "";
|
|
const toolCalls = [];
|
|
|
|
for (const block of responseBody.content) {
|
|
if (block.type === "text") textContent += block.text;
|
|
else if (block.type === "thinking") thinkingContent += block.thinking || "";
|
|
else if (block.type === "tool_use") {
|
|
toolCalls.push({ id: block.id, type: "function", function: { name: block.name, arguments: JSON.stringify(block.input || {}) } });
|
|
}
|
|
}
|
|
|
|
const message = { role: "assistant" };
|
|
if (textContent) message.content = textContent;
|
|
if (thinkingContent) message.reasoning_content = thinkingContent;
|
|
if (toolCalls.length > 0) message.tool_calls = toolCalls;
|
|
if (!message.content && !message.tool_calls) message.content = "";
|
|
|
|
let finishReason = responseBody.stop_reason || "stop";
|
|
if (finishReason === "end_turn") finishReason = "stop";
|
|
if (finishReason === "tool_use") finishReason = "tool_calls";
|
|
|
|
const result = {
|
|
id: `chatcmpl-${responseBody.id || Date.now()}`,
|
|
object: "chat.completion",
|
|
created: Math.floor(Date.now() / 1000),
|
|
model: responseBody.model || "claude",
|
|
choices: [{ index: 0, message, finish_reason: finishReason }]
|
|
};
|
|
|
|
if (responseBody.usage) {
|
|
result.usage = {
|
|
prompt_tokens: responseBody.usage.input_tokens || 0,
|
|
completion_tokens: responseBody.usage.output_tokens || 0,
|
|
total_tokens: (responseBody.usage.input_tokens || 0) + (responseBody.usage.output_tokens || 0)
|
|
};
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Ollama
|
|
if (targetFormat === FORMATS.OLLAMA) {
|
|
return ollamaBodyToOpenAI(responseBody);
|
|
}
|
|
|
|
return responseBody;
|
|
}
|
|
|
|
/**
|
|
* Handle non-streaming response from provider.
|
|
*/
|
|
export async function handleNonStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, trackDone, appendLog }) {
|
|
trackDone();
|
|
const contentType = providerResponse.headers.get("content-type") || "";
|
|
let responseBody;
|
|
|
|
if (contentType.includes("text/event-stream")) {
|
|
const sseText = await providerResponse.text();
|
|
const parsed = parseSSEToOpenAIResponse(sseText, model);
|
|
if (!parsed) {
|
|
appendLog({ status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}` });
|
|
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Invalid SSE response for non-streaming request");
|
|
}
|
|
responseBody = parsed;
|
|
} else {
|
|
try {
|
|
responseBody = await providerResponse.json();
|
|
} catch (err) {
|
|
appendLog({ status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}` });
|
|
console.error(`[ChatCore] Failed to parse JSON from ${provider}:`, err.message);
|
|
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, `Invalid JSON response from ${provider}`);
|
|
}
|
|
}
|
|
|
|
reqLogger.logProviderResponse(providerResponse.status, providerResponse.statusText, providerResponse.headers, responseBody);
|
|
if (onRequestSuccess) await onRequestSuccess();
|
|
|
|
const usage = extractUsageFromResponse(responseBody);
|
|
appendLog({ tokens: usage, status: "200 OK" });
|
|
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint });
|
|
|
|
const translatedResponse = needsTranslation(targetFormat, sourceFormat)
|
|
? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat)
|
|
: responseBody;
|
|
|
|
// Ensure OpenAI-required fields
|
|
if (!translatedResponse.object) translatedResponse.object = "chat.completion";
|
|
if (!translatedResponse.created) translatedResponse.created = Math.floor(Date.now() / 1000);
|
|
|
|
// Strip Azure-specific fields
|
|
delete translatedResponse.prompt_filter_results;
|
|
if (translatedResponse?.choices) {
|
|
for (const choice of translatedResponse.choices) delete choice.content_filter_results;
|
|
}
|
|
|
|
if (translatedResponse?.usage) {
|
|
translatedResponse.usage = filterUsageForFormat(addBufferToUsage(translatedResponse.usage), sourceFormat);
|
|
}
|
|
|
|
reqLogger.logConvertedResponse(translatedResponse);
|
|
|
|
const totalLatency = Date.now() - requestStartTime;
|
|
saveRequestDetail(buildRequestDetail({
|
|
provider, model, connectionId,
|
|
latency: { ttft: totalLatency, total: totalLatency },
|
|
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
|
|
request: extractRequestConfig(body, stream),
|
|
providerRequest: finalBody || translatedBody || null,
|
|
providerResponse: responseBody || null,
|
|
response: {
|
|
content: translatedResponse?.choices?.[0]?.message?.content || translatedResponse?.content || null,
|
|
thinking: translatedResponse?.choices?.[0]?.message?.reasoning_content || translatedResponse?.reasoning_content || null,
|
|
finish_reason: translatedResponse?.choices?.[0]?.finish_reason || "unknown"
|
|
},
|
|
status: "success"
|
|
}, { endpoint: clientRawRequest?.endpoint || null })).catch(err => {
|
|
console.error("[RequestDetail] Failed to save:", err.message);
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
response: new Response(JSON.stringify(translatedResponse), {
|
|
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }
|
|
})
|
|
};
|
|
}
|