227 lines
6.4 KiB
TypeScript
227 lines
6.4 KiB
TypeScript
// server/utils/openai.ts - OpenAI Chat Completions 流式调用工具
|
||
import { serverEnv } from "~~/server/utils/env";
|
||
|
||
/** 旧项目的 `OPENAI_API_BASE` 语义是完整 base,例如 `https://api.openai.com/v1` */
|
||
const CHAT_COMPLETIONS_URL = `${serverEnv.openAiApiBase}/chat/completions`;
|
||
/** 复用编码器计算 SSE data 字节长度,避免循环里频繁创建对象 */
|
||
const textEncoder = new TextEncoder();
|
||
|
||
/** Chat Completions SSE 每个 `data:` chunk 的最小结构 */
|
||
interface ChatCompletionStreamChunk {
|
||
id?: string;
|
||
object?: string;
|
||
created?: number;
|
||
model?: string;
|
||
choices?: Array<{
|
||
delta?: {
|
||
content?: string;
|
||
role?: string;
|
||
};
|
||
finish_reason?: string | null;
|
||
index?: number;
|
||
}>;
|
||
usage?: unknown;
|
||
}
|
||
|
||
/** 读取完整 SSE 后汇总出的信息;只在服务端日志或内部状态中使用 */
|
||
interface ChatCompletionStreamResult {
|
||
/** 累积的 `choices[].delta.content` 文本 */
|
||
content: string;
|
||
/** content 字符长度,方便排查模型是否返回了异常长文本 */
|
||
contentLength: number;
|
||
/** 实际解析到的 SSE JSON chunk 数量 */
|
||
chunkCount: number;
|
||
/** 模型返回的结束原因,例如 stop、length;可能不存在 */
|
||
finishReason?: string | null;
|
||
/** 上游如果返回 usage,则保留下来供内部排查 */
|
||
usage?: unknown;
|
||
}
|
||
|
||
/** 答题调用结果:对外只用 answer,upstreamResponse 留给服务端排查 */
|
||
export interface AskAnswerStreamResult {
|
||
answer: string;
|
||
upstreamResponse: ChatCompletionStreamResult;
|
||
}
|
||
|
||
/**
|
||
* 通过 Chat Completions 流式接口生成答案
|
||
*
|
||
* 对 OCS 客户端仍返回普通 JSON;流式只发生在服务端到 OpenAI 之间
|
||
* 这样既能更早读取上游内容,又不破坏 AnswererWrapper 的 handler 契约
|
||
*/
|
||
export const askAnswerStream = async ({
|
||
prompt,
|
||
systemPrompt
|
||
}: {
|
||
prompt: string;
|
||
systemPrompt: string;
|
||
}): Promise<AskAnswerStreamResult> => {
|
||
if (!serverEnv.openAiApiKey) {
|
||
throw new Error("OPENAI_API_KEY is not set.");
|
||
}
|
||
|
||
// 这里不要记录 request body:其中包含完整题目和可能的选项文本
|
||
const response = await fetch(CHAT_COMPLETIONS_URL, {
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: `Bearer ${serverEnv.openAiApiKey}`,
|
||
"Content-Type": "application/json"
|
||
},
|
||
body: JSON.stringify({
|
||
model: serverEnv.openAiModel,
|
||
temperature: serverEnv.temperature,
|
||
max_tokens: serverEnv.maxTokens,
|
||
stream: true,
|
||
messages: [
|
||
{
|
||
role: "system",
|
||
content: systemPrompt
|
||
},
|
||
{
|
||
role: "user",
|
||
content: prompt
|
||
}
|
||
]
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`OpenAI request failed with status ${response.status}.`);
|
||
}
|
||
|
||
if (!response.body) {
|
||
throw new Error("OpenAI stream body is empty.");
|
||
}
|
||
|
||
// 将 SSE delta 读完后再返回给 API handler,由 handler 统一做答案清洗和缓存
|
||
const upstreamResponse = await readChatCompletionStream(response.body);
|
||
|
||
return {
|
||
answer: upstreamResponse.content.trim(),
|
||
upstreamResponse
|
||
};
|
||
};
|
||
|
||
/**
|
||
* 读取 Chat Completions SSE 流
|
||
*
|
||
* 上游返回形如:
|
||
* `data: {"choices":[{"delta":{"content":"..."}}]}`
|
||
* `data: [DONE]`
|
||
*
|
||
* 网络 chunk 不一定按行对齐,所以用 buffer 保存尚未拼完整的一行
|
||
*/
|
||
export const readChatCompletionStream = async (
|
||
stream: ReadableStream<Uint8Array>
|
||
): Promise<ChatCompletionStreamResult> => {
|
||
const reader = stream.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = "";
|
||
let content = "";
|
||
let chunkCount = 0;
|
||
let finishReason: string | null | undefined;
|
||
let usage: unknown;
|
||
|
||
/** 把一个已解析 JSON chunk 合并进最终结果 */
|
||
const collectChunk = (chunk: ChatCompletionStreamChunk) => {
|
||
chunkCount += 1;
|
||
|
||
const deltaContent = collectDeltaContent(chunk);
|
||
if (deltaContent) {
|
||
content += deltaContent;
|
||
}
|
||
|
||
if (chunk.usage) {
|
||
usage = chunk.usage;
|
||
}
|
||
|
||
finishReason =
|
||
chunk.choices?.find((choice) => choice.finish_reason)?.finish_reason ??
|
||
finishReason;
|
||
};
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
// stream: true 可以正确处理跨 chunk 的多字节字符,例如中文
|
||
buffer += decoder.decode(value, { stream: true });
|
||
assertSseBufferSize(buffer, serverEnv.maxSseLineBytes);
|
||
|
||
// 只处理已经遇到换行的完整 SSE 行,最后一段留到下次 chunk 再拼
|
||
const lines = buffer.split(/\r?\n/u);
|
||
buffer = lines.pop() ?? "";
|
||
|
||
for (const line of lines) {
|
||
const chunk = parseSseDataLine(line, serverEnv.maxSseLineBytes);
|
||
if (chunk) collectChunk(chunk);
|
||
}
|
||
}
|
||
|
||
// 处理流结束时仍留在 buffer 中的最后一行
|
||
const finalText = buffer + decoder.decode();
|
||
for (const line of finalText.split(/\r?\n/u)) {
|
||
const chunk = parseSseDataLine(line, serverEnv.maxSseLineBytes);
|
||
if (chunk) collectChunk(chunk);
|
||
}
|
||
|
||
return {
|
||
content,
|
||
contentLength: content.length,
|
||
chunkCount,
|
||
finishReason,
|
||
usage
|
||
};
|
||
};
|
||
|
||
/**
|
||
* 解析单行 SSE data
|
||
*
|
||
* 空行、event 行和 `[DONE]` 都不包含业务内容,直接跳过
|
||
* 超过大小限制的 data 行视为异常,防止上游或代理故障导致内存压力
|
||
*/
|
||
export const parseSseDataLine = (
|
||
line: string,
|
||
maxDataLineBytes: number
|
||
): ChatCompletionStreamChunk | null => {
|
||
const trimmed = line.trim();
|
||
if (!trimmed.startsWith("data:")) return null;
|
||
|
||
const data = trimmed.slice("data:".length).trim();
|
||
if (!data || data === "[DONE]") return null;
|
||
|
||
if (textEncoder.encode(data).byteLength > maxDataLineBytes) {
|
||
throw new Error("OpenAI stream data line is too large.");
|
||
}
|
||
|
||
return JSON.parse(data) as ChatCompletionStreamChunk;
|
||
};
|
||
|
||
/** 收集一个 chunk 中所有 choice 的增量文本 */
|
||
export const collectDeltaContent = (chunk: ChatCompletionStreamChunk) => {
|
||
return (
|
||
chunk.choices
|
||
?.map((choice) => choice.delta?.content || "")
|
||
.filter(Boolean)
|
||
.join("") || ""
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 限制尚未出现换行的 SSE 缓冲区大小
|
||
*
|
||
* 正常 SSE 会频繁换行;如果一直没有换行且持续变大,说明可能不是合法 SSE
|
||
* 或上游返回了异常长单行,应该尽早失败
|
||
*/
|
||
export const assertSseBufferSize = (
|
||
buffer: string,
|
||
maxDataLineBytes: number
|
||
) => {
|
||
if (
|
||
!buffer.includes("\n") &&
|
||
textEncoder.encode(buffer).byteLength > maxDataLineBytes
|
||
) {
|
||
throw new Error("OpenAI stream buffer is too large.");
|
||
}
|
||
};
|