|
import { ServerRequest } from "https://deno.land/[email protected]/http/server.ts";
|
|
|
|
interface OpenAIChoice {
|
|
index: number;
|
|
message?: {
|
|
role: string;
|
|
content: string;
|
|
};
|
|
delta?: {
|
|
content: string;
|
|
};
|
|
finish_reason: string | null;
|
|
}
|
|
interface OpenAIUsage {
|
|
prompt_tokens: number;
|
|
completion_tokens: number;
|
|
total_tokens: number;
|
|
}
|
|
interface OpenAIResponse {
|
|
id: string;
|
|
object: string;
|
|
created: number;
|
|
model: string;
|
|
choices: OpenAIChoice[];
|
|
usage?: OpenAIUsage;
|
|
}
|
|
export class ResponseBuilder {
|
|
private static log(
|
|
level: "info" | "warn" | "error",
|
|
message: string,
|
|
data?: any
|
|
) {
|
|
const timestamp = new Date().toISOString();
|
|
const logData = data ? ` | Data: ${JSON.stringify(data)}` : "";
|
|
console[level](`[${timestamp}] [ResponseBuilder] ${message}${logData}`);
|
|
}
|
|
|
|
|
|
static buildNonStreamResponse(
|
|
modelName: string,
|
|
fullContent: string,
|
|
finishReason: string = "stop"
|
|
): Response {
|
|
this.log("info", "Building non-stream response", {
|
|
modelName,
|
|
contentLength: fullContent.length,
|
|
});
|
|
|
|
const response = {
|
|
id: "Chat-Nekohy",
|
|
object: "chat.completion",
|
|
created: Math.floor(Date.now() / 1000),
|
|
model: modelName,
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
message: { role: "assistant", content: fullContent },
|
|
finish_reason: finishReason,
|
|
},
|
|
],
|
|
};
|
|
|
|
return new Response(JSON.stringify(response), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
|
|
static buildSSEChunk(
|
|
model: string,
|
|
content: string,
|
|
isFinish: boolean = false
|
|
): string {
|
|
const response = {
|
|
id: "chatcmpl-Nekohy",
|
|
object: "chat.completion.chunk",
|
|
created: Math.floor(Date.now() / 1000),
|
|
model,
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: isFinish ? {} : { content },
|
|
finish_reason: isFinish ? "stop" : null,
|
|
},
|
|
],
|
|
};
|
|
|
|
const chunk = `data: ${JSON.stringify(response)}\n\n`;
|
|
return isFinish ? chunk + "data: [DONE]\n\n" : chunk;
|
|
}
|
|
|
|
|
|
static buildResponse(
|
|
readableStream: ReadableStream,
|
|
stream: boolean = false,
|
|
modelName: string = "default"
|
|
): Response {
|
|
this.log("info", "Building response", { stream, modelName });
|
|
|
|
const transformedStream = new ReadableStream({
|
|
start: async (controller) => {
|
|
const reader = readableStream.getReader();
|
|
const decoder = new TextDecoder();
|
|
let fullContent = "";
|
|
let chunkCount = 0;
|
|
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
const chunk = decoder.decode(value, { stream: true });
|
|
const content = this.extractContent(chunk);
|
|
|
|
if (content) {
|
|
chunkCount++;
|
|
fullContent += content;
|
|
|
|
if (stream) {
|
|
const sseChunk = this.buildSSEChunk(modelName, content);
|
|
controller.enqueue(new TextEncoder().encode(sseChunk));
|
|
}
|
|
}
|
|
}
|
|
|
|
this.log("info", "Stream processing completed", {
|
|
chunkCount,
|
|
totalLength: fullContent.length,
|
|
stream,
|
|
});
|
|
|
|
if (stream) {
|
|
|
|
const finishChunk = this.buildSSEChunk(modelName, "", true);
|
|
controller.enqueue(new TextEncoder().encode(finishChunk));
|
|
} else {
|
|
|
|
const response = this.buildNonStreamResponse(
|
|
modelName,
|
|
fullContent
|
|
);
|
|
const responseText = await response.text();
|
|
controller.enqueue(new TextEncoder().encode(responseText));
|
|
}
|
|
|
|
controller.close();
|
|
} catch (error) {
|
|
this.log("error", "Stream processing failed", {
|
|
error: error.message,
|
|
});
|
|
controller.error(error);
|
|
} finally {
|
|
reader.releaseLock();
|
|
}
|
|
},
|
|
});
|
|
|
|
const headers = stream
|
|
? {
|
|
"Content-Type": "text/event-stream",
|
|
Connection: "keep-alive",
|
|
"Cache-Control": "no-cache",
|
|
}
|
|
: { "Content-Type": "application/json" };
|
|
|
|
return new Response(transformedStream, { status: 200, headers });
|
|
}
|
|
|
|
|
|
private static extractContent(chunk: string): string {
|
|
let content = "";
|
|
const lines = chunk.split("\n");
|
|
|
|
for (const line of lines) {
|
|
const trimmedLine = line.trim();
|
|
if (trimmedLine.startsWith("data: ") && !trimmedLine.includes("[DONE]")) {
|
|
try {
|
|
const jsonStr = trimmedLine.slice(6).trim();
|
|
if (jsonStr) {
|
|
const data = JSON.parse(jsonStr);
|
|
if (data.message && typeof data.message === "string") {
|
|
content += data.message;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
this.log("warn", "Failed to parse SSE data", {
|
|
line: trimmedLine,
|
|
error: e.message,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return content;
|
|
}
|
|
|
|
|
|
static jsonResponse(data: unknown, status = 200): Response {
|
|
this.log("info", "Creating JSON response", { status });
|
|
return new Response(JSON.stringify(data), {
|
|
status,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
}
|
|
|
|
export function streamResponse(body: ReadableStream): Response {
|
|
return new Response(body, {
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type": "text/event-stream",
|
|
Connection: "keep-alive",
|
|
"Cache-Control": "no-cache",
|
|
},
|
|
});
|
|
}
|
|
|
|
export function unauthorizedResponse(message: string, status = 401): Response {
|
|
return new Response(JSON.stringify({ error: message }), {
|
|
status,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"WWW-Authenticate": "Bearer",
|
|
},
|
|
});
|
|
}
|
|
|
|
export function errorResponse(message: string, status = 500): Response {
|
|
return ResponseBuilder.jsonResponse({ error: message }, status);
|
|
}
|
|
|