100 lines
3.4 KiB
TypeScript
100 lines
3.4 KiB
TypeScript
export type OpenAiCompatibleChatMessage = {
|
|
role: "system" | "user" | "assistant";
|
|
content: string;
|
|
};
|
|
|
|
export type OpenAiCompatibleChatOptions = {
|
|
baseUrl: string; // 形如 https://host/v1
|
|
apiKey: string;
|
|
model: string;
|
|
timeoutMs?: number;
|
|
maxTokens?: number;
|
|
// 某些 OpenAI 兼容网关使用 max_completion_tokens 字段;如需可传入该值
|
|
maxCompletionTokens?: number;
|
|
responseFormat?: "json_object";
|
|
};
|
|
|
|
export const tryExtractJsonObject = (value: string): Record<string, unknown> | null => {
|
|
const s = value ?? "";
|
|
const start = s.indexOf("{");
|
|
const end = s.lastIndexOf("}");
|
|
if (start === -1 || end === -1 || end <= start) return null;
|
|
try {
|
|
const parsed = JSON.parse(s.slice(start, end + 1));
|
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
return parsed as Record<string, unknown>;
|
|
}
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
export const openAiCompatibleChat = async (
|
|
messages: OpenAiCompatibleChatMessage[],
|
|
opts: OpenAiCompatibleChatOptions,
|
|
): Promise<{ text: string; raw: unknown }> => {
|
|
const timeoutMs = Math.max(500, Math.min(120_000, opts.timeoutMs ?? 20_000));
|
|
const controller = new AbortController();
|
|
const t = setTimeout(() => controller.abort(), timeoutMs);
|
|
try {
|
|
const url = `${opts.baseUrl.replace(/\/+$/, "")}/chat/completions`;
|
|
const maxCompletionTokens =
|
|
typeof opts.maxCompletionTokens === "number" && Number.isFinite(opts.maxCompletionTokens)
|
|
? Math.max(64, Math.min(16_000, Math.floor(opts.maxCompletionTokens)))
|
|
: undefined;
|
|
const maxTokens =
|
|
typeof opts.maxTokens === "number" && Number.isFinite(opts.maxTokens)
|
|
? Math.max(64, Math.min(16_000, Math.floor(opts.maxTokens)))
|
|
: undefined;
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
signal: controller.signal,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${opts.apiKey}`,
|
|
},
|
|
body: JSON.stringify({
|
|
model: opts.model,
|
|
stream: false,
|
|
temperature: 0.2,
|
|
...(maxTokens ? { max_tokens: maxTokens } : {}),
|
|
...(maxCompletionTokens ? { max_completion_tokens: maxCompletionTokens } : {}),
|
|
...(opts.responseFormat === "json_object" ? { response_format: { type: "json_object" } } : {}),
|
|
messages,
|
|
}),
|
|
});
|
|
|
|
const raw = (await res.json().catch(() => null)) as unknown;
|
|
if (!res.ok) {
|
|
const errText =
|
|
typeof raw === "object" && raw && "error" in (raw as any)
|
|
? String((raw as any).error?.message ?? (raw as any).error)
|
|
: `HTTP ${res.status}`;
|
|
throw new Error(`在线 AI 调用失败:${errText}`);
|
|
}
|
|
|
|
const choiceText =
|
|
(raw as any)?.choices?.[0]?.message?.content ??
|
|
(raw as any)?.choices?.[0]?.text ??
|
|
"";
|
|
const text = String(choiceText ?? "");
|
|
return { text, raw };
|
|
} finally {
|
|
clearTimeout(t);
|
|
}
|
|
};
|
|
|
|
export const openAiCompatibleChatJson = async (
|
|
messages: OpenAiCompatibleChatMessage[],
|
|
opts: OpenAiCompatibleChatOptions,
|
|
): Promise<{ json: Record<string, unknown>; text: string; raw: unknown }> => {
|
|
const { text, raw } = await openAiCompatibleChat(messages, opts);
|
|
const json = tryExtractJsonObject(text);
|
|
if (!json) {
|
|
const preview = String(text || "").slice(0, 220).replace(/\s+/g, " ").trim();
|
|
throw new Error(`在线 AI 未返回可解析的 JSON 对象:${preview || "(empty)"}`);
|
|
}
|
|
return { json, text, raw };
|
|
};
|