forked from ChatGPTNextWeb/NextChat
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Loading status checks…
Merge pull request ChatGPTNextWeb#5157 from ConnectAI-E/feature/tencent
Feature/tencent
Showing
12 changed files
with
658 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,124 @@ | ||
import { getServerSideConfig } from "@/app/config/server"; | ||
import { | ||
TENCENT_BASE_URL, | ||
ApiPath, | ||
ModelProvider, | ||
ServiceProvider, | ||
Tencent, | ||
} from "@/app/constant"; | ||
import { prettyObject } from "@/app/utils/format"; | ||
import { NextRequest, NextResponse } from "next/server"; | ||
import { auth } from "@/app/api/auth"; | ||
import { isModelAvailableInServer } from "@/app/utils/model"; | ||
import { getHeader } from "@/app/utils/tencent"; | ||
|
||
const serverConfig = getServerSideConfig(); | ||
|
||
async function handle( | ||
req: NextRequest, | ||
{ params }: { params: { path: string[] } }, | ||
) { | ||
console.log("[Tencent Route] params ", params); | ||
|
||
if (req.method === "OPTIONS") { | ||
return NextResponse.json({ body: "OK" }, { status: 200 }); | ||
} | ||
|
||
const authResult = auth(req, ModelProvider.Hunyuan); | ||
if (authResult.error) { | ||
return NextResponse.json(authResult, { | ||
status: 401, | ||
}); | ||
} | ||
|
||
try { | ||
const response = await request(req); | ||
return response; | ||
} catch (e) { | ||
console.error("[Tencent] ", e); | ||
return NextResponse.json(prettyObject(e)); | ||
} | ||
} | ||
|
||
export const GET = handle; | ||
export const POST = handle; | ||
|
||
export const runtime = "nodejs"; | ||
export const preferredRegion = [ | ||
"arn1", | ||
"bom1", | ||
"cdg1", | ||
"cle1", | ||
"cpt1", | ||
"dub1", | ||
"fra1", | ||
"gru1", | ||
"hnd1", | ||
"iad1", | ||
"icn1", | ||
"kix1", | ||
"lhr1", | ||
"pdx1", | ||
"sfo1", | ||
"sin1", | ||
"syd1", | ||
]; | ||
|
||
async function request(req: NextRequest) { | ||
const controller = new AbortController(); | ||
|
||
let baseUrl = serverConfig.tencentUrl || TENCENT_BASE_URL; | ||
|
||
if (!baseUrl.startsWith("http")) { | ||
baseUrl = `https://${baseUrl}`; | ||
} | ||
|
||
if (baseUrl.endsWith("/")) { | ||
baseUrl = baseUrl.slice(0, -1); | ||
} | ||
|
||
console.log("[Base Url]", baseUrl); | ||
|
||
const timeoutId = setTimeout( | ||
() => { | ||
controller.abort(); | ||
}, | ||
10 * 60 * 1000, | ||
); | ||
|
||
const fetchUrl = baseUrl; | ||
|
||
const body = await req.text(); | ||
const headers = await getHeader( | ||
body, | ||
serverConfig.tencentSecretId as string, | ||
serverConfig.tencentSecretKey as string, | ||
); | ||
const fetchOptions: RequestInit = { | ||
headers, | ||
method: req.method, | ||
body, | ||
redirect: "manual", | ||
// @ts-ignore | ||
duplex: "half", | ||
signal: controller.signal, | ||
}; | ||
|
||
try { | ||
const res = await fetch(fetchUrl, fetchOptions); | ||
|
||
// to prevent browser prompt for credentials | ||
const newHeaders = new Headers(res.headers); | ||
newHeaders.delete("www-authenticate"); | ||
// to disable nginx buffering | ||
newHeaders.set("X-Accel-Buffering", "no"); | ||
|
||
return new Response(res.body, { | ||
status: res.status, | ||
statusText: res.statusText, | ||
headers: newHeaders, | ||
}); | ||
} finally { | ||
clearTimeout(timeoutId); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,267 @@ | ||
"use client"; | ||
import { ApiPath, DEFAULT_API_HOST, REQUEST_TIMEOUT_MS } from "@/app/constant"; | ||
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; | ||
|
||
import { | ||
ChatOptions, | ||
getHeaders, | ||
LLMApi, | ||
LLMModel, | ||
MultimodalContent, | ||
} from "../api"; | ||
import Locale from "../../locales"; | ||
import { | ||
EventStreamContentType, | ||
fetchEventSource, | ||
} from "@fortaine/fetch-event-source"; | ||
import { prettyObject } from "@/app/utils/format"; | ||
import { getClientConfig } from "@/app/config/client"; | ||
import { getMessageTextContent, isVisionModel } from "@/app/utils"; | ||
import mapKeys from "lodash-es/mapKeys"; | ||
import mapValues from "lodash-es/mapValues"; | ||
import isArray from "lodash-es/isArray"; | ||
import isObject from "lodash-es/isObject"; | ||
|
||
export interface OpenAIListModelResponse { | ||
object: string; | ||
data: Array<{ | ||
id: string; | ||
object: string; | ||
root: string; | ||
}>; | ||
} | ||
|
||
interface RequestPayload { | ||
Messages: { | ||
Role: "system" | "user" | "assistant"; | ||
Content: string | MultimodalContent[]; | ||
}[]; | ||
Stream?: boolean; | ||
Model: string; | ||
Temperature: number; | ||
TopP: number; | ||
} | ||
|
||
function capitalizeKeys(obj: any): any { | ||
if (isArray(obj)) { | ||
return obj.map(capitalizeKeys); | ||
} else if (isObject(obj)) { | ||
return mapValues( | ||
mapKeys(obj, (value: any, key: string) => | ||
key.replace(/(^|_)(\w)/g, (m, $1, $2) => $2.toUpperCase()), | ||
), | ||
capitalizeKeys, | ||
); | ||
} else { | ||
return obj; | ||
} | ||
} | ||
|
||
export class HunyuanApi implements LLMApi { | ||
path(): string { | ||
const accessStore = useAccessStore.getState(); | ||
|
||
let baseUrl = ""; | ||
|
||
if (accessStore.useCustomConfig) { | ||
baseUrl = accessStore.tencentUrl; | ||
} | ||
|
||
if (baseUrl.length === 0) { | ||
const isApp = !!getClientConfig()?.isApp; | ||
baseUrl = isApp | ||
? DEFAULT_API_HOST + "/api/proxy/tencent" | ||
: ApiPath.Tencent; | ||
} | ||
|
||
if (baseUrl.endsWith("/")) { | ||
baseUrl = baseUrl.slice(0, baseUrl.length - 1); | ||
} | ||
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Tencent)) { | ||
baseUrl = "https://" + baseUrl; | ||
} | ||
|
||
console.log("[Proxy Endpoint] ", baseUrl); | ||
return baseUrl; | ||
} | ||
|
||
extractMessage(res: any) { | ||
return res.Choices?.at(0)?.Message?.Content ?? ""; | ||
} | ||
|
||
async chat(options: ChatOptions) { | ||
const visionModel = isVisionModel(options.config.model); | ||
const messages = options.messages.map((v) => ({ | ||
role: v.role, | ||
content: visionModel ? v.content : getMessageTextContent(v), | ||
})); | ||
|
||
const modelConfig = { | ||
...useAppConfig.getState().modelConfig, | ||
...useChatStore.getState().currentSession().mask.modelConfig, | ||
...{ | ||
model: options.config.model, | ||
}, | ||
}; | ||
|
||
const requestPayload: RequestPayload = capitalizeKeys({ | ||
model: modelConfig.model, | ||
messages, | ||
temperature: modelConfig.temperature, | ||
top_p: modelConfig.top_p, | ||
stream: options.config.stream, | ||
}); | ||
|
||
console.log("[Request] Tencent payload: ", requestPayload); | ||
|
||
const shouldStream = !!options.config.stream; | ||
const controller = new AbortController(); | ||
options.onController?.(controller); | ||
|
||
try { | ||
const chatPath = this.path(); | ||
const chatPayload = { | ||
method: "POST", | ||
body: JSON.stringify(requestPayload), | ||
signal: controller.signal, | ||
headers: getHeaders(), | ||
}; | ||
|
||
// make a fetch request | ||
const requestTimeoutId = setTimeout( | ||
() => controller.abort(), | ||
REQUEST_TIMEOUT_MS, | ||
); | ||
|
||
if (shouldStream) { | ||
let responseText = ""; | ||
let remainText = ""; | ||
let finished = false; | ||
|
||
// animate response to make it looks smooth | ||
function animateResponseText() { | ||
if (finished || controller.signal.aborted) { | ||
responseText += remainText; | ||
console.log("[Response Animation] finished"); | ||
if (responseText?.length === 0) { | ||
options.onError?.(new Error("empty response from server")); | ||
} | ||
return; | ||
} | ||
|
||
if (remainText.length > 0) { | ||
const fetchCount = Math.max(1, Math.round(remainText.length / 60)); | ||
const fetchText = remainText.slice(0, fetchCount); | ||
responseText += fetchText; | ||
remainText = remainText.slice(fetchCount); | ||
options.onUpdate?.(responseText, fetchText); | ||
} | ||
|
||
requestAnimationFrame(animateResponseText); | ||
} | ||
|
||
// start animaion | ||
animateResponseText(); | ||
|
||
const finish = () => { | ||
if (!finished) { | ||
finished = true; | ||
options.onFinish(responseText + remainText); | ||
} | ||
}; | ||
|
||
controller.signal.onabort = finish; | ||
|
||
fetchEventSource(chatPath, { | ||
...chatPayload, | ||
async onopen(res) { | ||
clearTimeout(requestTimeoutId); | ||
const contentType = res.headers.get("content-type"); | ||
console.log( | ||
"[Tencent] request response content type: ", | ||
contentType, | ||
); | ||
|
||
if (contentType?.startsWith("text/plain")) { | ||
responseText = await res.clone().text(); | ||
return finish(); | ||
} | ||
|
||
if ( | ||
!res.ok || | ||
!res.headers | ||
.get("content-type") | ||
?.startsWith(EventStreamContentType) || | ||
res.status !== 200 | ||
) { | ||
const responseTexts = [responseText]; | ||
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); | ||
} | ||
|
||
responseText = responseTexts.join("\n\n"); | ||
|
||
return finish(); | ||
} | ||
}, | ||
onmessage(msg) { | ||
if (msg.data === "[DONE]" || finished) { | ||
return finish(); | ||
} | ||
const text = msg.data; | ||
try { | ||
const json = JSON.parse(text); | ||
const choices = json.Choices as Array<{ | ||
Delta: { Content: string }; | ||
}>; | ||
const delta = choices[0]?.Delta?.Content; | ||
if (delta) { | ||
remainText += delta; | ||
} | ||
} catch (e) { | ||
console.error("[Request] parse error", text, msg); | ||
} | ||
}, | ||
onclose() { | ||
finish(); | ||
}, | ||
onerror(e) { | ||
options.onError?.(e); | ||
throw e; | ||
}, | ||
openWhenHidden: true, | ||
}); | ||
} else { | ||
const res = await fetch(chatPath, chatPayload); | ||
clearTimeout(requestTimeoutId); | ||
|
||
const resJson = await res.json(); | ||
const message = this.extractMessage(resJson); | ||
options.onFinish(message); | ||
} | ||
} catch (e) { | ||
console.log("[Request] failed to make a chat request", e); | ||
options.onError?.(e as Error); | ||
} | ||
} | ||
async usage() { | ||
return { | ||
used: 0, | ||
total: 0, | ||
}; | ||
} | ||
|
||
async models(): Promise<LLMModel[]> { | ||
return []; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,109 @@ | ||
import { createHash, createHmac } from "node:crypto"; | ||
// 使用 SHA-256 和 secret 进行 HMAC 加密 | ||
function sha256(message: any, secret = "", encoding?: string) { | ||
return createHmac("sha256", secret) | ||
.update(message) | ||
.digest(encoding as any); | ||
} | ||
|
||
// 使用 SHA-256 进行哈希 | ||
function getHash(message: any, encoding = "hex") { | ||
return createHash("sha256") | ||
.update(message) | ||
.digest(encoding as any); | ||
} | ||
|
||
function getDate(timestamp: number) { | ||
const date = new Date(timestamp * 1000); | ||
const year = date.getUTCFullYear(); | ||
const month = ("0" + (date.getUTCMonth() + 1)).slice(-2); | ||
const day = ("0" + date.getUTCDate()).slice(-2); | ||
return `${year}-${month}-${day}`; | ||
} | ||
|
||
export async function getHeader( | ||
payload: any, | ||
SECRET_ID: string, | ||
SECRET_KEY: string, | ||
) { | ||
// https://cloud.tencent.com/document/api/1729/105701 | ||
|
||
const endpoint = "hunyuan.tencentcloudapi.com"; | ||
const service = "hunyuan"; | ||
const region = ""; // optional | ||
const action = "ChatCompletions"; | ||
const version = "2023-09-01"; | ||
const timestamp = Math.floor(Date.now() / 1000); | ||
//时间处理, 获取世界时间日期 | ||
const date = getDate(timestamp); | ||
|
||
// ************* 步骤 1:拼接规范请求串 ************* | ||
|
||
const hashedRequestPayload = getHash(payload); | ||
const httpRequestMethod = "POST"; | ||
const contentType = "application/json"; | ||
const canonicalUri = "/"; | ||
const canonicalQueryString = ""; | ||
const canonicalHeaders = | ||
`content-type:${contentType}\n` + | ||
"host:" + | ||
endpoint + | ||
"\n" + | ||
"x-tc-action:" + | ||
action.toLowerCase() + | ||
"\n"; | ||
const signedHeaders = "content-type;host;x-tc-action"; | ||
|
||
const canonicalRequest = [ | ||
httpRequestMethod, | ||
canonicalUri, | ||
canonicalQueryString, | ||
canonicalHeaders, | ||
signedHeaders, | ||
hashedRequestPayload, | ||
].join("\n"); | ||
|
||
// ************* 步骤 2:拼接待签名字符串 ************* | ||
const algorithm = "TC3-HMAC-SHA256"; | ||
const hashedCanonicalRequest = getHash(canonicalRequest); | ||
const credentialScope = date + "/" + service + "/" + "tc3_request"; | ||
const stringToSign = | ||
algorithm + | ||
"\n" + | ||
timestamp + | ||
"\n" + | ||
credentialScope + | ||
"\n" + | ||
hashedCanonicalRequest; | ||
|
||
// ************* 步骤 3:计算签名 ************* | ||
const kDate = sha256(date, "TC3" + SECRET_KEY); | ||
const kService = sha256(service, kDate); | ||
const kSigning = sha256("tc3_request", kService); | ||
const signature = sha256(stringToSign, kSigning, "hex"); | ||
|
||
// ************* 步骤 4:拼接 Authorization ************* | ||
const authorization = | ||
algorithm + | ||
" " + | ||
"Credential=" + | ||
SECRET_ID + | ||
"/" + | ||
credentialScope + | ||
", " + | ||
"SignedHeaders=" + | ||
signedHeaders + | ||
", " + | ||
"Signature=" + | ||
signature; | ||
|
||
return { | ||
Authorization: authorization, | ||
"Content-Type": contentType, | ||
Host: endpoint, | ||
"X-TC-Action": action, | ||
"X-TC-Timestamp": timestamp.toString(), | ||
"X-TC-Version": version, | ||
"X-TC-Region": region, | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters