mirror of
https://github.com/Yidadaa/ChatGPT-Next-Web.git
synced 2025-10-10 14:39:20 +08:00
feat: add multi-model support
This commit is contained in:
20
app/client/openai/config.ts
Normal file
20
app/client/openai/config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { COMMON_PROVIDER_CONFIG } from "../common/config";
|
||||
|
||||
export const OpenAIConfig = {
|
||||
model: {
|
||||
model: "gpt-3.5-turbo" as string,
|
||||
summarizeModel: "gpt-3.5-turbo",
|
||||
|
||||
temperature: 0.5,
|
||||
top_p: 1,
|
||||
max_tokens: 2000,
|
||||
presence_penalty: 0,
|
||||
frequency_penalty: 0,
|
||||
},
|
||||
provider: {
|
||||
name: "OpenAI",
|
||||
endpoint: "https://api.openai.com",
|
||||
apiKey: "",
|
||||
...COMMON_PROVIDER_CONFIG,
|
||||
},
|
||||
};
|
295
app/client/openai/index.ts
Normal file
295
app/client/openai/index.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import {
|
||||
EventStreamContentType,
|
||||
fetchEventSource,
|
||||
} from "@fortaine/fetch-event-source";
|
||||
|
||||
import {
|
||||
API_PREFIX,
|
||||
ApiPath,
|
||||
DEFAULT_MODELS,
|
||||
OpenaiPath,
|
||||
} from "@/app/constant";
|
||||
import { ModelConfig, ProviderConfig } from "@/app/store";
|
||||
|
||||
import { OpenAI } from "./types";
|
||||
|
||||
import { ChatOptions, LLMModel, LLMUsage } from "../types";
|
||||
import Locale from "@/app/locales";
|
||||
|
||||
import { prettyObject } from "@/app/utils/format";
|
||||
import { getApiPath } from "@/app/utils/path";
|
||||
import { trimEnd } from "@/app/utils/string";
|
||||
import { omit } from "@/app/utils/object";
|
||||
import { createLogger } from "@/app/utils/log";
|
||||
import { getAuthHeaders } from "../common/auth";
|
||||
|
||||
export function createOpenAiClient(
|
||||
providerConfigs: ProviderConfig,
|
||||
modelConfig: ModelConfig,
|
||||
) {
|
||||
const openaiConfig = { ...providerConfigs.openai };
|
||||
const logger = createLogger("[OpenAI Client]");
|
||||
const openaiModelConfig = { ...modelConfig.openai };
|
||||
|
||||
return {
|
||||
headers() {
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders(openaiConfig.apiKey),
|
||||
};
|
||||
},
|
||||
|
||||
path(path: OpenaiPath): string {
|
||||
let baseUrl = openaiConfig.endpoint;
|
||||
|
||||
// if endpoint is empty, use default endpoint
|
||||
if (baseUrl.trim().length === 0) {
|
||||
baseUrl = getApiPath(ApiPath.OpenAI);
|
||||
}
|
||||
|
||||
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(API_PREFIX)) {
|
||||
baseUrl = "https://" + baseUrl;
|
||||
}
|
||||
|
||||
baseUrl = trimEnd(baseUrl, "/");
|
||||
|
||||
return `${baseUrl}/${path}`;
|
||||
},
|
||||
|
||||
extractMessage(res: OpenAI.ChatCompletionResponse) {
|
||||
return res.choices[0]?.message?.content ?? "";
|
||||
},
|
||||
|
||||
beforeRequest(options: ChatOptions, stream = false) {
|
||||
const messages = options.messages.map((v) => ({
|
||||
role: v.role,
|
||||
content: v.content,
|
||||
}));
|
||||
|
||||
if (options.shouldSummarize) {
|
||||
openaiModelConfig.model = openaiModelConfig.summarizeModel;
|
||||
}
|
||||
|
||||
const requestBody: OpenAI.ChatCompletionRequest = {
|
||||
messages,
|
||||
stream,
|
||||
...omit(openaiModelConfig, "summarizeModel"),
|
||||
};
|
||||
|
||||
const path = this.path(OpenaiPath.Chat);
|
||||
|
||||
logger.log("path = ", path, requestBody);
|
||||
|
||||
const controller = new AbortController();
|
||||
options.onController?.(controller);
|
||||
|
||||
const payload = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: controller.signal,
|
||||
headers: this.headers(),
|
||||
};
|
||||
|
||||
return {
|
||||
path,
|
||||
payload,
|
||||
controller,
|
||||
};
|
||||
},
|
||||
|
||||
async chat(options: ChatOptions) {
|
||||
try {
|
||||
const { path, payload, controller } = this.beforeRequest(
|
||||
options,
|
||||
false,
|
||||
);
|
||||
|
||||
controller.signal.onabort = () => options.onFinish("");
|
||||
|
||||
const res = await fetch(path, payload);
|
||||
const resJson = await res.json();
|
||||
|
||||
const message = this.extractMessage(resJson);
|
||||
options.onFinish(message);
|
||||
} catch (e) {
|
||||
logger.error("failed to chat", e);
|
||||
options.onError?.(e as Error);
|
||||
}
|
||||
},
|
||||
|
||||
async chatStream(options: ChatOptions) {
|
||||
try {
|
||||
const { path, payload, controller } = this.beforeRequest(options, true);
|
||||
|
||||
const context = {
|
||||
text: "",
|
||||
finished: false,
|
||||
};
|
||||
|
||||
const finish = () => {
|
||||
if (!context.finished) {
|
||||
options.onFinish(context.text);
|
||||
context.finished = true;
|
||||
}
|
||||
};
|
||||
|
||||
controller.signal.onabort = finish;
|
||||
|
||||
fetchEventSource(path, {
|
||||
...payload,
|
||||
async onopen(res) {
|
||||
const contentType = res.headers.get("content-type");
|
||||
logger.log("response content type: ", contentType);
|
||||
|
||||
if (contentType?.startsWith("text/plain")) {
|
||||
context.text = await res.clone().text();
|
||||
return finish();
|
||||
}
|
||||
|
||||
if (
|
||||
!res.ok ||
|
||||
!res.headers
|
||||
.get("content-type")
|
||||
?.startsWith(EventStreamContentType) ||
|
||||
res.status !== 200
|
||||
) {
|
||||
const responseTexts = [context.text];
|
||||
let extraInfo = await res.clone().text();
|
||||
try {
|
||||
const resJson = await res.clone().json();
|
||||
extraInfo = prettyObject(resJson);
|
||||
} catch {}
|
||||
|
||||
if (res.status === 401) {
|
||||
responseTexts.push(Locale.Error.Unauthorized);
|
||||
}
|
||||
|
||||
if (extraInfo) {
|
||||
responseTexts.push(extraInfo);
|
||||
}
|
||||
|
||||
context.text = responseTexts.join("\n\n");
|
||||
|
||||
return finish();
|
||||
}
|
||||
},
|
||||
onmessage(msg) {
|
||||
if (msg.data === "[DONE]" || context.finished) {
|
||||
return finish();
|
||||
}
|
||||
const chunk = msg.data;
|
||||
try {
|
||||
const chunkJson = JSON.parse(
|
||||
chunk,
|
||||
) as OpenAI.ChatCompletionStreamResponse;
|
||||
const delta = chunkJson.choices[0].delta.content;
|
||||
if (delta) {
|
||||
context.text += delta;
|
||||
options.onUpdate?.(context.text, delta);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error("[Request] parse error", chunk, msg);
|
||||
}
|
||||
},
|
||||
onclose() {
|
||||
finish();
|
||||
},
|
||||
onerror(e) {
|
||||
options.onError?.(e);
|
||||
},
|
||||
openWhenHidden: true,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("failed to chat", e);
|
||||
options.onError?.(e as Error);
|
||||
}
|
||||
},
|
||||
|
||||
async usage() {
|
||||
const formatDate = (d: Date) =>
|
||||
`${d.getFullYear()}-${(d.getMonth() + 1)
|
||||
.toString()
|
||||
.padStart(2, "0")}-${d.getDate().toString().padStart(2, "0")}`;
|
||||
const ONE_DAY = 1 * 24 * 60 * 60 * 1000;
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const startDate = formatDate(startOfMonth);
|
||||
const endDate = formatDate(new Date(Date.now() + ONE_DAY));
|
||||
|
||||
const [used, subs] = await Promise.all([
|
||||
fetch(
|
||||
`${this.path(
|
||||
OpenaiPath.Usage,
|
||||
)}?start_date=${startDate}&end_date=${endDate}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: this.headers(),
|
||||
},
|
||||
),
|
||||
fetch(this.path(OpenaiPath.Subs), {
|
||||
method: "GET",
|
||||
headers: this.headers(),
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!used.ok || !subs.ok) {
|
||||
throw new Error("Failed to query usage from openai");
|
||||
}
|
||||
|
||||
const response = (await used.json()) as {
|
||||
total_usage?: number;
|
||||
error?: {
|
||||
type: string;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
const total = (await subs.json()) as {
|
||||
hard_limit_usd?: number;
|
||||
};
|
||||
|
||||
if (response.error?.type) {
|
||||
throw Error(response.error?.message);
|
||||
}
|
||||
|
||||
response.total_usage = Math.round(response.total_usage ?? 0) / 100;
|
||||
total.hard_limit_usd =
|
||||
Math.round((total.hard_limit_usd ?? 0) * 100) / 100;
|
||||
|
||||
return {
|
||||
used: response.total_usage,
|
||||
total: total.hard_limit_usd,
|
||||
} as LLMUsage;
|
||||
},
|
||||
|
||||
async models(): Promise<LLMModel[]> {
|
||||
const customModels = openaiConfig.customModels
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.map((v) => ({
|
||||
name: v,
|
||||
available: true,
|
||||
}));
|
||||
|
||||
if (!openaiConfig.autoFetchModels) {
|
||||
return [...DEFAULT_MODELS.slice(), ...customModels];
|
||||
}
|
||||
|
||||
const res = await fetch(this.path(OpenaiPath.ListModel), {
|
||||
method: "GET",
|
||||
headers: this.headers(),
|
||||
});
|
||||
|
||||
const resJson = (await res.json()) as OpenAI.ListModelResponse;
|
||||
const chatModels =
|
||||
resJson.data?.filter((m) => m.id.startsWith("gpt-")) ?? [];
|
||||
|
||||
return chatModels
|
||||
.map((m) => ({
|
||||
name: m.id,
|
||||
available: true,
|
||||
}))
|
||||
.concat(customModels);
|
||||
},
|
||||
};
|
||||
}
|
79
app/client/openai/types.ts
Normal file
79
app/client/openai/types.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
export namespace OpenAI {
|
||||
export type Role = "system" | "user" | "assistant" | "function";
|
||||
export type FinishReason = "stop" | "length" | "function_call";
|
||||
|
||||
export interface Message {
|
||||
role: Role;
|
||||
content?: string;
|
||||
function_call?: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Function {
|
||||
name: string;
|
||||
description?: string;
|
||||
parameters: object;
|
||||
}
|
||||
|
||||
export interface ListModelResponse {
|
||||
object: string;
|
||||
data: Array<{
|
||||
id: string;
|
||||
object: string;
|
||||
root: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ChatCompletionChoice {
|
||||
index: number;
|
||||
message: Message;
|
||||
finish_reason: FinishReason;
|
||||
}
|
||||
|
||||
export interface ChatCompletionUsage {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
}
|
||||
|
||||
export interface ChatCompletionResponse {
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
model: string;
|
||||
choices: ChatCompletionChoice[];
|
||||
usage: ChatCompletionUsage;
|
||||
}
|
||||
|
||||
export interface ChatCompletionChunkChoice {
|
||||
index: number;
|
||||
delta: Message;
|
||||
finish_reason?: FinishReason;
|
||||
}
|
||||
|
||||
export interface ChatCompletionStreamResponse {
|
||||
object: string;
|
||||
created: number;
|
||||
model: string;
|
||||
choices: ChatCompletionChunkChoice[];
|
||||
}
|
||||
|
||||
export interface ChatCompletionRequest {
|
||||
model: string;
|
||||
messages: Message[];
|
||||
|
||||
functions?: Function[];
|
||||
function_call?: "none" | "auto";
|
||||
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
n?: number;
|
||||
stream?: boolean;
|
||||
stop?: string | string[];
|
||||
max_tokens?: number;
|
||||
presence_penalty?: number;
|
||||
frequency_penalty?: number;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user