From d726c71141aa41dc4e7a6ff5f980dafc0df7ba56 Mon Sep 17 00:00:00 2001 From: Dogtiti <499960698@qq.com> Date: Sun, 7 Jul 2024 14:43:28 +0800 Subject: [PATCH 1/9] wip: tencent --- app/api/tencent/[...path]/route.ts | 309 +++++++++++++++++++++++++++++ app/client/api.ts | 7 + app/client/platforms/tencent.ts | 259 ++++++++++++++++++++++++ app/config/server.ts | 11 + app/constant.ts | 28 +++ app/store/access.ts | 10 + package.json | 146 +++++++------- yarn.lock | 22 ++ 8 files changed, 721 insertions(+), 71 deletions(-) create mode 100644 app/api/tencent/[...path]/route.ts create mode 100644 app/client/platforms/tencent.ts diff --git a/app/api/tencent/[...path]/route.ts b/app/api/tencent/[...path]/route.ts new file mode 100644 index 00000000000..1a5d42f85f6 --- /dev/null +++ b/app/api/tencent/[...path]/route.ts @@ -0,0 +1,309 @@ +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 CryptoJS from "crypto-js"; +import mapKeys from "lodash-es/mapKeys"; +import mapValues from "lodash-es/mapValues"; +import isArray from "lodash-es/isArray"; +import isObject from "lodash-es/isObject"; + +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 = "edge"; +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(); + + // tencent just use base url or just remove the path + let path = `${req.nextUrl.pathname}`.replaceAll( + ApiPath.Tencent + "/" + Tencent.ChatPath, + "", + ); + + let baseUrl = serverConfig.tencentUrl || TENCENT_BASE_URL; + + if (!baseUrl.startsWith("http")) { + baseUrl = `https://${baseUrl}`; + } + + if (baseUrl.endsWith("/")) { + baseUrl = baseUrl.slice(0, -1); + } + + console.log("[Proxy] ", path); + console.log("[Base Url]", baseUrl); + + const timeoutId = setTimeout( + () => { + controller.abort(); + }, + 10 * 60 * 1000, + ); + + const fetchUrl = `${baseUrl}${path}`; + + let body = null; + if (req.body) { + const bodyText = await req.text(); + console.log( + "Dogtiti ~ request ~ capitalizeKeys(JSON.parse(bodyText):", + capitalizeKeys(JSON.parse(bodyText)), + ); + body = JSON.stringify(capitalizeKeys(JSON.parse(bodyText))); + } + + const fetchOptions: RequestInit = { + headers: { + ...getHeader(body), + }, + method: req.method, + body: '{"Model":"hunyuan-pro","Messages":[{"Role":"user","Content":"你好"}]}', // FIXME + redirect: "manual", + // @ts-ignore + duplex: "half", + signal: controller.signal, + }; + + // #1815 try to refuse some request to some models + if (serverConfig.customModels && req.body) { + try { + const clonedBody = await req.text(); + fetchOptions.body = clonedBody; + + const jsonBody = JSON.parse(clonedBody) as { model?: string }; + + // not undefined and is false + if ( + isModelAvailableInServer( + serverConfig.customModels, + jsonBody?.model as string, + ServiceProvider.Tencent as string, + ) + ) { + return NextResponse.json( + { + error: true, + message: `you are not allowed to use ${jsonBody?.model} model`, + }, + { + status: 403, + }, + ); + } + } catch (e) { + console.error(`[Tencent] filter`, e); + } + } + console.log("[Tencent request]", fetchOptions.headers, req.method); + try { + const res = await fetch(fetchUrl, fetchOptions); + + console.log("[Tencent response]", res.status, " ", res.headers, res.url); + // 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); + } +} + +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.charAt(0).toUpperCase() + key.slice(1), + ), + capitalizeKeys, + ); + } else { + return obj; + } +} + +// 使用 SHA-256 和 secret 进行 HMAC 加密 +function sha256(message: any, secret = "", encoding = "hex") { + const hmac = CryptoJS.HmacSHA256(message, secret); + if (encoding === "hex") { + return hmac.toString(CryptoJS.enc.Hex); + } else if (encoding === "base64") { + return hmac.toString(CryptoJS.enc.Base64); + } else { + return hmac.toString(); + } +} + +// 使用 SHA-256 进行哈希 +function getHash(message: any, encoding = "hex") { + const hash = CryptoJS.SHA256(message); + if (encoding === "hex") { + return hash.toString(CryptoJS.enc.Hex); + } else if (encoding === "base64") { + return hash.toString(CryptoJS.enc.Base64); + } else { + return hash.toString(); + } +} +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}`; +} + +function getHeader(payload: any) { + // https://cloud.tencent.com/document/api/1729/105701 + // 密钥参数 + const SECRET_ID = serverConfig.tencentSecretId; + const SECRET_KEY = serverConfig.tencentSecretKey; + + 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 canonicalUri = "/"; + const canonicalQueryString = ""; + const canonicalHeaders = + "content-type:application/json; charset=utf-8\n" + + "host:" + + endpoint + + "\n" + + "x-tc-action:" + + action.toLowerCase() + + "\n"; + const signedHeaders = "content-type;host;x-tc-action"; + + const canonicalRequest = + httpRequestMethod + + "\n" + + canonicalUri + + "\n" + + canonicalQueryString + + "\n" + + canonicalHeaders + + "\n" + + signedHeaders + + "\n" + + hashedRequestPayload; + + // ************* 步骤 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": "application/json; charset=utf-8", + Host: endpoint, + "X-TC-Action": action, + "X-TC-Timestamp": timestamp.toString(), + "X-TC-Version": version, + "X-TC-Region": region, + }; +} diff --git a/app/client/api.ts b/app/client/api.ts index 528a5598aec..c1c19431352 100644 --- a/app/client/api.ts +++ b/app/client/api.ts @@ -9,6 +9,8 @@ import { ChatMessage, ModelType, useAccessStore, useChatStore } from "../store"; import { ChatGPTApi } from "./platforms/openai"; import { GeminiProApi } from "./platforms/google"; import { ClaudeApi } from "./platforms/anthropic"; +import { HunyuanApi } from "./platforms/tencent"; + export const ROLES = ["system", "user", "assistant"] as const; export type MessageRole = (typeof ROLES)[number]; @@ -104,6 +106,9 @@ export class ClientApi { case ModelProvider.Claude: this.llm = new ClaudeApi(); break; + case ModelProvider.Hunyuan: + this.llm = new HunyuanApi(); + break; default: this.llm = new ChatGPTApi(); } @@ -220,6 +225,8 @@ export function getClientApi(provider: ServiceProvider): ClientApi { return new ClientApi(ModelProvider.GeminiPro); case ServiceProvider.Anthropic: return new ClientApi(ModelProvider.Claude); + case ServiceProvider.Tencent: + return new ClientApi(ModelProvider.Hunyuan); default: return new ClientApi(ModelProvider.GPT); } diff --git a/app/client/platforms/tencent.ts b/app/client/platforms/tencent.ts new file mode 100644 index 00000000000..d812c5a82d5 --- /dev/null +++ b/app/client/platforms/tencent.ts @@ -0,0 +1,259 @@ +"use client"; +import { + ApiPath, + DEFAULT_API_HOST, + REQUEST_TIMEOUT_MS, + Tencent, +} 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"; + +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; + presence_penalty: number; + frequency_penalty: number; + top_p: number; + max_tokens?: number; +} + +export class HunyuanApi implements LLMApi { + path(path: string): 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/bytedance" + : 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, path].join("/"); + } + + 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 = { + messages, + stream: options.config.stream, + model: modelConfig.model, + temperature: modelConfig.temperature, + presence_penalty: modelConfig.presence_penalty, + frequency_penalty: modelConfig.frequency_penalty, + top_p: modelConfig.top_p, + }; + + console.log("[Request] Tencent payload: ", requestPayload); + + const shouldStream = !!options.config.stream; + const controller = new AbortController(); + options.onController?.(controller); + + try { + const chatPath = this.path(Tencent.ChatPath); + 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 { + return []; + } +} diff --git a/app/config/server.ts b/app/config/server.ts index b7c85ce6a5f..f2590dc3202 100644 --- a/app/config/server.ts +++ b/app/config/server.ts @@ -35,6 +35,11 @@ declare global { // google tag manager GTM_ID?: string; + // tencent only + TENCENT_URL?: string; + TENCENT_SECRET_KEY?: string; + TENCENT_SECRET_ID?: string; + // custom template for preprocessing user input DEFAULT_INPUT_TEMPLATE?: string; } @@ -92,6 +97,7 @@ export const getServerSideConfig = () => { const isAzure = !!process.env.AZURE_URL; const isGoogle = !!process.env.GOOGLE_API_KEY; const isAnthropic = !!process.env.ANTHROPIC_API_KEY; + const isTencent = !!process.env.TENCENT_API_KEY; // const apiKeyEnvVar = process.env.OPENAI_API_KEY ?? ""; // const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim()); @@ -124,6 +130,11 @@ export const getServerSideConfig = () => { anthropicApiVersion: process.env.ANTHROPIC_API_VERSION, anthropicUrl: process.env.ANTHROPIC_URL, + isTencent, + tencentUrl: process.env.TENCENT_URL, + tencentSecretKey: getApiKey(process.env.TENCENT_SECRET_KEY), + tencentSecretId: process.env.TENCENT_SECRET_ID, + gtmId: process.env.GTM_ID, needCode: ACCESS_CODES.size > 0, diff --git a/app/constant.ts b/app/constant.ts index d44b5b8173b..d10546e1633 100644 --- a/app/constant.ts +++ b/app/constant.ts @@ -14,6 +14,8 @@ export const ANTHROPIC_BASE_URL = "https://api.anthropic.com"; export const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/"; +export const TENCENT_BASE_URL = "https://hunyuan.tencentcloudapi.com"; + export enum Path { Home = "/", Chat = "/chat", @@ -28,6 +30,7 @@ export enum ApiPath { Azure = "/api/azure", OpenAI = "/api/openai", Anthropic = "/api/anthropic", + Tencent = "/api/tencent", } export enum SlotID { @@ -71,12 +74,14 @@ export enum ServiceProvider { Azure = "Azure", Google = "Google", Anthropic = "Anthropic", + Tencent = "Tencent", } export enum ModelProvider { GPT = "GPT", GeminiPro = "GeminiPro", Claude = "Claude", + Hunyuan = "Hunyuan", } export const Anthropic = { @@ -104,6 +109,10 @@ export const Google = { ChatPath: (modelName: string) => `v1beta/models/${modelName}:generateContent`, }; +export const Tencent = { + ChatPath: "chat/completions", +}; + export const DEFAULT_INPUT_TEMPLATE = `{{input}}`; // input / time / model / lang // export const DEFAULT_SYSTEM_TEMPLATE = ` // You are ChatGPT, a large language model trained by {{ServiceProvider}}. @@ -173,6 +182,16 @@ const anthropicModels = [ "claude-3-5-sonnet-20240620", ]; +const tencentModels = [ + "hunyuan-pro", + "hunyuan-standard", + "hunyuan-lite", + "hunyuan-role", + "hunyuan-functioncall", + "hunyuan-code", + "hunyuan-vision", +]; + export const DEFAULT_MODELS = [ ...openaiModels.map((name) => ({ name, @@ -210,6 +229,15 @@ export const DEFAULT_MODELS = [ providerType: "anthropic", }, })), + ...tencentModels.map((name) => ({ + name, + available: true, + provider: { + id: "tencent", + providerName: "Tencent", + providerType: "tencent", + }, + })), ] as const; export const CHAT_PAGE_SIZE = 15; diff --git a/app/store/access.ts b/app/store/access.ts index 03780779e72..329a01cab17 100644 --- a/app/store/access.ts +++ b/app/store/access.ts @@ -47,6 +47,11 @@ const DEFAULT_ACCESS_STATE = { anthropicApiVersion: "2023-06-01", anthropicUrl: "", + // tencent + tencentUrl: "", + tencentSecretKey: "", + tencentSecretId: "", + // server config needCode: true, hideUserApiKey: false, @@ -83,6 +88,10 @@ export const useAccessStore = createPersistStore( return ensure(get(), ["anthropicApiKey"]); }, + isValidTencent() { + return ensure(get(), ["tencentSecretKey", "tencentSecretId"]); + }, + isAuthorized() { this.fetch(); @@ -92,6 +101,7 @@ export const useAccessStore = createPersistStore( this.isValidAzure() || this.isValidGoogle() || this.isValidAnthropic() || + this.isValidTencent || !this.enabledAccessControl() || (this.enabledAccessControl() && ensure(get(), ["accessCode"])) ); diff --git a/package.json b/package.json index ed5edb04330..372690fed27 100644 --- a/package.json +++ b/package.json @@ -1,73 +1,77 @@ { - "name": "nextchat", - "private": false, - "license": "mit", - "scripts": { - "mask": "npx tsx app/masks/build.ts", - "mask:watch": "npx watch 'yarn mask' app/masks", - "dev": "yarn run mask:watch & next dev", - "build": "yarn mask && cross-env BUILD_MODE=standalone next build", - "start": "next start", - "lint": "next lint", - "export": "yarn mask && cross-env BUILD_MODE=export BUILD_APP=1 next build", - "export:dev": "yarn mask:watch & cross-env BUILD_MODE=export BUILD_APP=1 next dev", - "app:dev": "yarn mask:watch & yarn tauri dev", - "app:build": "yarn mask && yarn tauri build", - "prompts": "node ./scripts/fetch-prompts.mjs", - "prepare": "husky install", - "proxy-dev": "sh ./scripts/init-proxy.sh && proxychains -f ./scripts/proxychains.conf yarn dev" - }, - "dependencies": { - "@fortaine/fetch-event-source": "^3.0.6", - "@hello-pangea/dnd": "^16.5.0", - "@next/third-parties": "^14.1.0", - "@svgr/webpack": "^6.5.1", - "@vercel/analytics": "^0.1.11", - "@vercel/speed-insights": "^1.0.2", - "emoji-picker-react": "^4.9.2", - "fuse.js": "^7.0.0", - "heic2any": "^0.0.4", - "html-to-image": "^1.11.11", - "mermaid": "^10.6.1", - "nanoid": "^5.0.3", - "next": "^14.1.1", - "node-fetch": "^3.3.1", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-markdown": "^8.0.7", - "react-router-dom": "^6.15.0", - "rehype-highlight": "^6.0.0", - "rehype-katex": "^6.0.3", - "remark-breaks": "^3.0.2", - "remark-gfm": "^3.0.1", - "remark-math": "^5.1.1", - "sass": "^1.59.2", - "spark-md5": "^3.0.2", - "use-debounce": "^9.0.4", - "zustand": "^4.3.8" - }, - "devDependencies": { - "@tauri-apps/cli": "1.5.11", - "@types/node": "^20.11.30", - "@types/react": "^18.2.70", - "@types/react-dom": "^18.2.7", - "@types/react-katex": "^3.0.0", - "@types/spark-md5": "^3.0.4", - "cross-env": "^7.0.3", - "eslint": "^8.49.0", - "eslint-config-next": "13.4.19", - "eslint-config-prettier": "^8.8.0", - "eslint-plugin-prettier": "^5.1.3", - "husky": "^8.0.0", - "lint-staged": "^13.2.2", - "prettier": "^3.0.2", - "tsx": "^4.16.0", - "typescript": "5.2.2", - "watch": "^1.0.2", - "webpack": "^5.88.1" - }, - "resolutions": { - "lint-staged/yaml": "^2.2.2" - }, - "packageManager": "yarn@1.22.19" + "name": "nextchat", + "private": false, + "license": "mit", + "scripts": { + "mask": "npx tsx app/masks/build.ts", + "mask:watch": "npx watch 'yarn mask' app/masks", + "dev": "yarn run mask:watch & next dev", + "build": "yarn mask && cross-env BUILD_MODE=standalone next build", + "start": "next start", + "lint": "next lint", + "export": "yarn mask && cross-env BUILD_MODE=export BUILD_APP=1 next build", + "export:dev": "yarn mask:watch & cross-env BUILD_MODE=export BUILD_APP=1 next dev", + "app:dev": "yarn mask:watch & yarn tauri dev", + "app:build": "yarn mask && yarn tauri build", + "prompts": "node ./scripts/fetch-prompts.mjs", + "prepare": "husky install", + "proxy-dev": "sh ./scripts/init-proxy.sh && proxychains -f ./scripts/proxychains.conf yarn dev" + }, + "dependencies": { + "@fortaine/fetch-event-source": "^3.0.6", + "@hello-pangea/dnd": "^16.5.0", + "@next/third-parties": "^14.1.0", + "@svgr/webpack": "^6.5.1", + "@vercel/analytics": "^0.1.11", + "@vercel/speed-insights": "^1.0.2", + "crypto-js": "^4.2.0", + "emoji-picker-react": "^4.9.2", + "fuse.js": "^7.0.0", + "heic2any": "^0.0.4", + "html-to-image": "^1.11.11", + "lodash-es": "^4.17.21", + "mermaid": "^10.6.1", + "nanoid": "^5.0.3", + "next": "^14.1.1", + "node-fetch": "^3.3.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-markdown": "^8.0.7", + "react-router-dom": "^6.15.0", + "rehype-highlight": "^6.0.0", + "rehype-katex": "^6.0.3", + "remark-breaks": "^3.0.2", + "remark-gfm": "^3.0.1", + "remark-math": "^5.1.1", + "sass": "^1.59.2", + "spark-md5": "^3.0.2", + "use-debounce": "^9.0.4", + "zustand": "^4.3.8" + }, + "devDependencies": { + "@tauri-apps/cli": "1.5.11", + "@types/crypto-js": "^4.2.2", + "@types/lodash-es": "^4.17.12", + "@types/node": "^20.11.30", + "@types/react": "^18.2.70", + "@types/react-dom": "^18.2.7", + "@types/react-katex": "^3.0.0", + "@types/spark-md5": "^3.0.4", + "cross-env": "^7.0.3", + "eslint": "^8.49.0", + "eslint-config-next": "13.4.19", + "eslint-config-prettier": "^8.8.0", + "eslint-plugin-prettier": "^5.1.3", + "husky": "^8.0.0", + "lint-staged": "^13.2.2", + "prettier": "^3.0.2", + "tsx": "^4.16.0", + "typescript": "5.2.2", + "watch": "^1.0.2", + "webpack": "^5.88.1" + }, + "resolutions": { + "lint-staged/yaml": "^2.2.2" + }, + "packageManager": "yarn@1.22.19" } diff --git a/yarn.lock b/yarn.lock index c323a5c38db..685abb04ed7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1617,6 +1617,11 @@ resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad" integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA== +"@types/crypto-js@^4.2.2": + version "4.2.2" + resolved "https://registry.npmmirror.com/@types/crypto-js/-/crypto-js-4.2.2.tgz#771c4a768d94eb5922cc202a3009558204df0cea" + integrity sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ== + "@types/d3-scale-chromatic@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.0.tgz#103124777e8cdec85b20b51fd3397c682ee1e954" @@ -1697,6 +1702,18 @@ resolved "https://registry.yarnpkg.com/@types/katex/-/katex-0.14.0.tgz#b84c0afc3218069a5ad64fe2a95321881021b5fe" integrity sha512-+2FW2CcT0K3P+JMR8YG846bmDwplKUTsWgT2ENwdQ1UdVfRk3GQrh6Mi4sTopy30gI8Uau5CEqHTDZ6YvWIUPA== +"@types/lodash-es@^4.17.12": + version "4.17.12" + resolved "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz#65f6d1e5f80539aa7cfbfc962de5def0cf4f341b" + integrity sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ== + dependencies: + "@types/lodash" "*" + +"@types/lodash@*": + version "4.17.6" + resolved "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.6.tgz#193ced6a40c8006cfc1ca3f4553444fb38f0e543" + integrity sha512-OpXEVoCKSS3lQqjx9GGGOapBeuW5eUboYHRlHP9urXPX25IKZ6AnP5ZRxtVf63iieUbsHxLn8NQ5Nlftc6yzAA== + "@types/mdast@^3.0.0": version "3.0.11" resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.11.tgz#dc130f7e7d9306124286f6d6cee40cf4d14a3dc0" @@ -2447,6 +2464,11 @@ cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" +crypto-js@^4.2.0: + version "4.2.0" + resolved "https://registry.npmmirror.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631" + integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q== + css-box-model@^1.2.1: version "1.2.1" resolved "https://registry.npmmirror.com/css-box-model/-/css-box-model-1.2.1.tgz#59951d3b81fd6b2074a62d49444415b0d2b4d7c1" From 40cbabc330591ca1261bcc53113be5deba1eaae7 Mon Sep 17 00:00:00 2001 From: lloydzhou Date: Thu, 1 Aug 2024 11:55:22 +0800 Subject: [PATCH 2/9] add moonshot api --- app/api/auth.ts | 3 + app/api/moonshot/[...path]/route.ts | 154 +++++++++++++++++ app/client/api.ts | 10 ++ app/client/platforms/moonshot.ts | 251 ++++++++++++++++++++++++++++ app/config/server.ts | 9 + app/constant.ts | 20 +++ app/store/access.ts | 13 ++ 7 files changed, 460 insertions(+) create mode 100644 app/api/moonshot/[...path]/route.ts create mode 100644 app/client/platforms/moonshot.ts diff --git a/app/api/auth.ts b/app/api/auth.ts index 2913a147795..ff52dcd6ee3 100644 --- a/app/api/auth.ts +++ b/app/api/auth.ts @@ -85,6 +85,9 @@ export function auth(req: NextRequest, modelProvider: ModelProvider) { case ModelProvider.Qwen: systemApiKey = serverConfig.alibabaApiKey; break; + case ModelProvider.Moonshot: + systemApiKey = serverConfig.moonshotApiKey; + break; case ModelProvider.GPT: default: if (req.nextUrl.pathname.includes("azure/deployments")) { diff --git a/app/api/moonshot/[...path]/route.ts b/app/api/moonshot/[...path]/route.ts new file mode 100644 index 00000000000..14bc0a40d92 --- /dev/null +++ b/app/api/moonshot/[...path]/route.ts @@ -0,0 +1,154 @@ +import { getServerSideConfig } from "@/app/config/server"; +import { + Moonshot, + MOONSHOT_BASE_URL, + ApiPath, + ModelProvider, + ServiceProvider, +} 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 type { RequestPayload } from "@/app/client/platforms/openai"; + +const serverConfig = getServerSideConfig(); + +async function handle( + req: NextRequest, + { params }: { params: { path: string[] } }, +) { + console.log("[Moonshot Route] params ", params); + + if (req.method === "OPTIONS") { + return NextResponse.json({ body: "OK" }, { status: 200 }); + } + + const authResult = auth(req, ModelProvider.Moonshot); + if (authResult.error) { + return NextResponse.json(authResult, { + status: 401, + }); + } + + try { + const response = await request(req); + return response; + } catch (e) { + console.error("[Moonshot] ", e); + return NextResponse.json(prettyObject(e)); + } +} + +export const GET = handle; +export const POST = handle; + +export const runtime = "edge"; +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(); + + // alibaba use base url or just remove the path + let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Moonshot, ""); + + let baseUrl = serverConfig.moonshotUrl || MOONSHOT_BASE_URL; + + if (!baseUrl.startsWith("http")) { + baseUrl = `https://${baseUrl}`; + } + + if (baseUrl.endsWith("/")) { + baseUrl = baseUrl.slice(0, -1); + } + + console.log("[Proxy] ", path); + console.log("[Base Url]", baseUrl); + + const timeoutId = setTimeout( + () => { + controller.abort(); + }, + 10 * 60 * 1000, + ); + + const fetchUrl = `${baseUrl}${path}`; + const fetchOptions: RequestInit = { + headers: { + "Content-Type": "application/json", + Authorization: req.headers.get("Authorization") ?? "", + }, + method: req.method, + body: req.body, + redirect: "manual", + // @ts-ignore + duplex: "half", + signal: controller.signal, + }; + + // #1815 try to refuse some request to some models + if (serverConfig.customModels && req.body) { + try { + const clonedBody = await req.text(); + fetchOptions.body = clonedBody; + + const jsonBody = JSON.parse(clonedBody) as { model?: string }; + + // not undefined and is false + if ( + isModelAvailableInServer( + serverConfig.customModels, + jsonBody?.model as string, + ServiceProvider.Moonshot as string, + ) + ) { + return NextResponse.json( + { + error: true, + message: `you are not allowed to use ${jsonBody?.model} model`, + }, + { + status: 403, + }, + ); + } + } catch (e) { + console.error(`[Moonshot] filter`, e); + } + } + 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); + } +} diff --git a/app/client/api.ts b/app/client/api.ts index 102a4220ff2..43fc1e42323 100644 --- a/app/client/api.ts +++ b/app/client/api.ts @@ -12,6 +12,7 @@ import { ClaudeApi } from "./platforms/anthropic"; import { ErnieApi } from "./platforms/baidu"; import { DoubaoApi } from "./platforms/bytedance"; import { QwenApi } from "./platforms/alibaba"; +import { MoonshotApi } from "./platforms/moonshot"; export const ROLES = ["system", "user", "assistant"] as const; export type MessageRole = (typeof ROLES)[number]; @@ -117,6 +118,9 @@ export class ClientApi { case ModelProvider.Qwen: this.llm = new QwenApi(); break; + case ModelProvider.Moonshot: + this.llm = new MoonshotApi(); + break; default: this.llm = new ChatGPTApi(); } @@ -199,6 +203,7 @@ export function getHeaders() { const isBaidu = modelConfig.providerName == ServiceProvider.Baidu; const isByteDance = modelConfig.providerName === ServiceProvider.ByteDance; const isAlibaba = modelConfig.providerName === ServiceProvider.Alibaba; + const isMoonshot = modelConfig.providerName === ServiceProvider.Moonshot; const isEnabledAccessControl = accessStore.enabledAccessControl(); const apiKey = isGoogle ? accessStore.googleApiKey @@ -210,6 +215,8 @@ export function getHeaders() { ? accessStore.bytedanceApiKey : isAlibaba ? accessStore.alibabaApiKey + : isMoonshot + ? accessStore.moonshotApiKey : accessStore.openaiApiKey; return { isGoogle, @@ -218,6 +225,7 @@ export function getHeaders() { isBaidu, isByteDance, isAlibaba, + isMoonshot, apiKey, isEnabledAccessControl, }; @@ -267,6 +275,8 @@ export function getClientApi(provider: ServiceProvider): ClientApi { return new ClientApi(ModelProvider.Doubao); case ServiceProvider.Alibaba: return new ClientApi(ModelProvider.Qwen); + case ServiceProvider.Moonshot: + return new ClientApi(ModelProvider.Moonshot); default: return new ClientApi(ModelProvider.GPT); } diff --git a/app/client/platforms/moonshot.ts b/app/client/platforms/moonshot.ts new file mode 100644 index 00000000000..7d257ccb2e6 --- /dev/null +++ b/app/client/platforms/moonshot.ts @@ -0,0 +1,251 @@ +"use client"; +// azure and openai, using same models. so using same LLMApi. +import { + ApiPath, + DEFAULT_API_HOST, + DEFAULT_MODELS, + Moonshot, + REQUEST_TIMEOUT_MS, + ServiceProvider, +} from "@/app/constant"; +import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; +import { collectModelsWithDefaultModel } from "@/app/utils/model"; +import { preProcessImageContent } from "@/app/utils/chat"; +import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare"; + +import { + ChatOptions, + getHeaders, + LLMApi, + LLMModel, + LLMUsage, + 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 } from "@/app/utils"; + +import { OpenAIListModelResponse, RequestPayload } from "./openai"; + +export class MoonshotApi implements LLMApi { + private disableListModels = true; + + path(path: string): string { + const accessStore = useAccessStore.getState(); + + let baseUrl = ""; + + if (accessStore.useCustomConfig) { + baseUrl = accessStore.moonshotUrl; + } + + if (baseUrl.length === 0) { + const isApp = !!getClientConfig()?.isApp; + const apiPath = ApiPath.Moonshot; + baseUrl = isApp ? DEFAULT_API_HOST + "/proxy" + apiPath : apiPath; + } + + if (baseUrl.endsWith("/")) { + baseUrl = baseUrl.slice(0, baseUrl.length - 1); + } + if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Moonshot)) { + baseUrl = "https://" + baseUrl; + } + + console.log("[Proxy Endpoint] ", baseUrl, path); + + return [baseUrl, path].join("/"); + } + + extractMessage(res: any) { + return res.choices?.at(0)?.message?.content ?? ""; + } + + async chat(options: ChatOptions) { + const messages: ChatOptions["messages"] = []; + for (const v of options.messages) { + const content = getMessageTextContent(v); + messages.push({ role: v.role, content }); + } + + const modelConfig = { + ...useAppConfig.getState().modelConfig, + ...useChatStore.getState().currentSession().mask.modelConfig, + ...{ + model: options.config.model, + providerName: options.config.providerName, + }, + }; + + const requestPayload: RequestPayload = { + messages, + stream: options.config.stream, + model: modelConfig.model, + temperature: modelConfig.temperature, + presence_penalty: modelConfig.presence_penalty, + frequency_penalty: modelConfig.frequency_penalty, + top_p: modelConfig.top_p, + // max_tokens: Math.max(modelConfig.max_tokens, 1024), + // Please do not ask me why not send max_tokens, no reason, this param is just shit, I dont want to explain anymore. + }; + + console.log("[Request] openai payload: ", requestPayload); + + const shouldStream = !!options.config.stream; + const controller = new AbortController(); + options.onController?.(controller); + + try { + const chatPath = this.path(Moonshot.ChatPath); + 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( + "[OpenAI] 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; + const textmoderation = json?.prompt_filter_results; + + 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 { + return []; + } +} diff --git a/app/config/server.ts b/app/config/server.ts index 718fa48283d..be13a182737 100644 --- a/app/config/server.ts +++ b/app/config/server.ts @@ -57,6 +57,10 @@ declare global { ALIBABA_URL?: string; ALIBABA_API_KEY?: string; + // moonshot only + MOONSHOT_URL?: string; + MOONSHOT_API_KEY?: string; + // custom template for preprocessing user input DEFAULT_INPUT_TEMPLATE?: string; } @@ -120,6 +124,7 @@ export const getServerSideConfig = () => { const isBaidu = !!process.env.BAIDU_API_KEY; const isBytedance = !!process.env.BYTEDANCE_API_KEY; const isAlibaba = !!process.env.ALIBABA_API_KEY; + const isMoonshot = !!process.env.MOONSHOT_API_KEY; // const apiKeyEnvVar = process.env.OPENAI_API_KEY ?? ""; // const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim()); // const randomIndex = Math.floor(Math.random() * apiKeys.length); @@ -168,6 +173,10 @@ export const getServerSideConfig = () => { alibabaUrl: process.env.ALIBABA_URL, alibabaApiKey: getApiKey(process.env.ALIBABA_API_KEY), + isMoonshot, + moonshotUrl: process.env.MOONSHOT_URL, + moonshotApiKey: getApiKey(process.env.MOONSHOT_API_KEY), + gtmId: process.env.GTM_ID, needCode: ACCESS_CODES.size > 0, diff --git a/app/constant.ts b/app/constant.ts index 0eb3f41e955..5eadcc8fea2 100644 --- a/app/constant.ts +++ b/app/constant.ts @@ -22,6 +22,7 @@ export const BAIDU_OATUH_URL = `${BAIDU_BASE_URL}/oauth/2.0/token`; export const BYTEDANCE_BASE_URL = "https://ark.cn-beijing.volces.com"; export const ALIBABA_BASE_URL = "https://dashscope.aliyuncs.com/api/"; +export const MOONSHOT_BASE_URL = "https://api.moonshot.cn"; export const CACHE_URL_PREFIX = "/api/cache"; export const UPLOAD_URL = `${CACHE_URL_PREFIX}/upload`; @@ -46,6 +47,7 @@ export enum ApiPath { Baidu = "/api/baidu", ByteDance = "/api/bytedance", Alibaba = "/api/alibaba", + Moonshot = "/api/moonshot", Stability = "/api/stability", } @@ -94,6 +96,7 @@ export enum ServiceProvider { Baidu = "Baidu", ByteDance = "ByteDance", Alibaba = "Alibaba", + Moonshot = "Moonshot", Stability = "Stability", } @@ -114,6 +117,7 @@ export enum ModelProvider { Ernie = "Ernie", Doubao = "Doubao", Qwen = "Qwen", + Moonshot = "Moonshot", } export const Stability = { @@ -177,6 +181,11 @@ export const Alibaba = { ChatPath: "v1/services/aigc/text-generation/generation", }; +export const Moonshot = { + ExampleEndpoint: MOONSHOT_BASE_URL, + ChatPath: "v1/chat/completions", +}; + export const DEFAULT_INPUT_TEMPLATE = `{{input}}`; // input / time / model / lang // export const DEFAULT_SYSTEM_TEMPLATE = ` // You are ChatGPT, a large language model trained by {{ServiceProvider}}. @@ -283,6 +292,8 @@ const alibabaModes = [ "qwen-max-longcontext", ]; +const moonshotModes = ["moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"]; + export const DEFAULT_MODELS = [ ...openaiModels.map((name) => ({ name, @@ -347,6 +358,15 @@ export const DEFAULT_MODELS = [ providerType: "alibaba", }, })), + ...moonshotModes.map((name) => ({ + name, + available: true, + provider: { + id: "moonshot", + providerName: "Moonshot", + providerType: "moonshot", + }, + })), ] as const; export const CHAT_PAGE_SIZE = 15; diff --git a/app/store/access.ts b/app/store/access.ts index a204d35c9c3..7c28bd53c85 100644 --- a/app/store/access.ts +++ b/app/store/access.ts @@ -39,6 +39,10 @@ const DEFAULT_ALIBABA_URL = isApp ? DEFAULT_API_HOST + "/api/proxy/alibaba" : ApiPath.Alibaba; +const DEFAULT_MOONSHOT_URL = isApp + ? DEFAULT_API_HOST + "/api/proxy/moonshot" + : ApiPath.Moonshot; + const DEFAULT_STABILITY_URL = isApp ? DEFAULT_API_HOST + "/api/proxy/stability" : ApiPath.Stability; @@ -82,6 +86,10 @@ const DEFAULT_ACCESS_STATE = { alibabaUrl: DEFAULT_ALIBABA_URL, alibabaApiKey: "", + // moonshot + moonshotUrl: DEFAULT_MOONSHOT_URL, + moonshotApiKey: "", + //stability stabilityUrl: DEFAULT_STABILITY_URL, stabilityApiKey: "", @@ -134,6 +142,10 @@ export const useAccessStore = createPersistStore( return ensure(get(), ["alibabaApiKey"]); }, + isValidMoonshot() { + return ensure(get(), ["moonshotApiKey"]); + }, + isAuthorized() { this.fetch(); @@ -146,6 +158,7 @@ export const useAccessStore = createPersistStore( this.isValidBaidu() || this.isValidByteDance() || this.isValidAlibaba() || + this.isValidMoonshot() || !this.enabledAccessControl() || (this.enabledAccessControl() && ensure(get(), ["accessCode"])) ); From 966db1e4be2e8ac8351f5bb81cadb255d1970af4 Mon Sep 17 00:00:00 2001 From: lloydzhou Date: Thu, 1 Aug 2024 12:03:22 +0800 Subject: [PATCH 3/9] add moonshot settings --- app/components/settings.tsx | 41 +++++++++++++++++++++++++++++++++++++ app/locales/cn.ts | 11 ++++++++++ app/locales/en.ts | 11 ++++++++++ 3 files changed, 63 insertions(+) diff --git a/app/components/settings.tsx b/app/components/settings.tsx index bde3a792ae7..76b12f7ffd0 100644 --- a/app/components/settings.tsx +++ b/app/components/settings.tsx @@ -56,6 +56,7 @@ import { Baidu, ByteDance, Alibaba, + Moonshot, Google, GoogleSafetySettingsThreshold, OPENAI_BASE_URL, @@ -1042,6 +1043,45 @@ export function Settings() { ); + const moonshotConfigComponent = accessStore.provider === + ServiceProvider.Moonshot && ( + <> + + + accessStore.update( + (access) => (access.moonshotUrl = e.currentTarget.value), + ) + } + > + + + { + accessStore.update( + (access) => (access.moonshotApiKey = e.currentTarget.value), + ); + }} + /> + + + ); + const stabilityConfigComponent = accessStore.provider === ServiceProvider.Stability && ( <> @@ -1364,6 +1404,7 @@ export function Settings() { {baiduConfigComponent} {byteDanceConfigComponent} {alibabaConfigComponent} + {moonshotConfigComponent} {stabilityConfigComponent} )} diff --git a/app/locales/cn.ts b/app/locales/cn.ts index cae41bfeeac..f632d1b751c 100644 --- a/app/locales/cn.ts +++ b/app/locales/cn.ts @@ -393,6 +393,17 @@ const cn = { SubTitle: "样例:", }, }, + Moonshot: { + ApiKey: { + Title: "接口密钥", + SubTitle: "使用自定义月之暗面API Key", + Placeholder: "Moonshot API Key", + }, + Endpoint: { + Title: "接口地址", + SubTitle: "样例:", + }, + }, Stability: { ApiKey: { Title: "接口密钥", diff --git a/app/locales/en.ts b/app/locales/en.ts index bfb383e8f0f..9a214ba5048 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -376,6 +376,17 @@ const en: LocaleType = { SubTitle: "Example: ", }, }, + Moonshot: { + ApiKey: { + Title: "Moonshot API Key", + SubTitle: "Use a custom Moonshot API Key", + Placeholder: "Moonshot API Key", + }, + Endpoint: { + Title: "Endpoint Address", + SubTitle: "Example: ", + }, + }, Stability: { ApiKey: { Title: "Stability API Key", From a024980c03ce02f70184643d39260f62edcdc4da Mon Sep 17 00:00:00 2001 From: lloydzhou Date: Thu, 1 Aug 2024 13:21:36 +0800 Subject: [PATCH 4/9] revert package.json --- package.json | 146 +++++++++++++++++++++++++-------------------------- yarn.lock | 22 -------- 2 files changed, 71 insertions(+), 97 deletions(-) diff --git a/package.json b/package.json index 372690fed27..ed5edb04330 100644 --- a/package.json +++ b/package.json @@ -1,77 +1,73 @@ { - "name": "nextchat", - "private": false, - "license": "mit", - "scripts": { - "mask": "npx tsx app/masks/build.ts", - "mask:watch": "npx watch 'yarn mask' app/masks", - "dev": "yarn run mask:watch & next dev", - "build": "yarn mask && cross-env BUILD_MODE=standalone next build", - "start": "next start", - "lint": "next lint", - "export": "yarn mask && cross-env BUILD_MODE=export BUILD_APP=1 next build", - "export:dev": "yarn mask:watch & cross-env BUILD_MODE=export BUILD_APP=1 next dev", - "app:dev": "yarn mask:watch & yarn tauri dev", - "app:build": "yarn mask && yarn tauri build", - "prompts": "node ./scripts/fetch-prompts.mjs", - "prepare": "husky install", - "proxy-dev": "sh ./scripts/init-proxy.sh && proxychains -f ./scripts/proxychains.conf yarn dev" - }, - "dependencies": { - "@fortaine/fetch-event-source": "^3.0.6", - "@hello-pangea/dnd": "^16.5.0", - "@next/third-parties": "^14.1.0", - "@svgr/webpack": "^6.5.1", - "@vercel/analytics": "^0.1.11", - "@vercel/speed-insights": "^1.0.2", - "crypto-js": "^4.2.0", - "emoji-picker-react": "^4.9.2", - "fuse.js": "^7.0.0", - "heic2any": "^0.0.4", - "html-to-image": "^1.11.11", - "lodash-es": "^4.17.21", - "mermaid": "^10.6.1", - "nanoid": "^5.0.3", - "next": "^14.1.1", - "node-fetch": "^3.3.1", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-markdown": "^8.0.7", - "react-router-dom": "^6.15.0", - "rehype-highlight": "^6.0.0", - "rehype-katex": "^6.0.3", - "remark-breaks": "^3.0.2", - "remark-gfm": "^3.0.1", - "remark-math": "^5.1.1", - "sass": "^1.59.2", - "spark-md5": "^3.0.2", - "use-debounce": "^9.0.4", - "zustand": "^4.3.8" - }, - "devDependencies": { - "@tauri-apps/cli": "1.5.11", - "@types/crypto-js": "^4.2.2", - "@types/lodash-es": "^4.17.12", - "@types/node": "^20.11.30", - "@types/react": "^18.2.70", - "@types/react-dom": "^18.2.7", - "@types/react-katex": "^3.0.0", - "@types/spark-md5": "^3.0.4", - "cross-env": "^7.0.3", - "eslint": "^8.49.0", - "eslint-config-next": "13.4.19", - "eslint-config-prettier": "^8.8.0", - "eslint-plugin-prettier": "^5.1.3", - "husky": "^8.0.0", - "lint-staged": "^13.2.2", - "prettier": "^3.0.2", - "tsx": "^4.16.0", - "typescript": "5.2.2", - "watch": "^1.0.2", - "webpack": "^5.88.1" - }, - "resolutions": { - "lint-staged/yaml": "^2.2.2" - }, - "packageManager": "yarn@1.22.19" + "name": "nextchat", + "private": false, + "license": "mit", + "scripts": { + "mask": "npx tsx app/masks/build.ts", + "mask:watch": "npx watch 'yarn mask' app/masks", + "dev": "yarn run mask:watch & next dev", + "build": "yarn mask && cross-env BUILD_MODE=standalone next build", + "start": "next start", + "lint": "next lint", + "export": "yarn mask && cross-env BUILD_MODE=export BUILD_APP=1 next build", + "export:dev": "yarn mask:watch & cross-env BUILD_MODE=export BUILD_APP=1 next dev", + "app:dev": "yarn mask:watch & yarn tauri dev", + "app:build": "yarn mask && yarn tauri build", + "prompts": "node ./scripts/fetch-prompts.mjs", + "prepare": "husky install", + "proxy-dev": "sh ./scripts/init-proxy.sh && proxychains -f ./scripts/proxychains.conf yarn dev" + }, + "dependencies": { + "@fortaine/fetch-event-source": "^3.0.6", + "@hello-pangea/dnd": "^16.5.0", + "@next/third-parties": "^14.1.0", + "@svgr/webpack": "^6.5.1", + "@vercel/analytics": "^0.1.11", + "@vercel/speed-insights": "^1.0.2", + "emoji-picker-react": "^4.9.2", + "fuse.js": "^7.0.0", + "heic2any": "^0.0.4", + "html-to-image": "^1.11.11", + "mermaid": "^10.6.1", + "nanoid": "^5.0.3", + "next": "^14.1.1", + "node-fetch": "^3.3.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-markdown": "^8.0.7", + "react-router-dom": "^6.15.0", + "rehype-highlight": "^6.0.0", + "rehype-katex": "^6.0.3", + "remark-breaks": "^3.0.2", + "remark-gfm": "^3.0.1", + "remark-math": "^5.1.1", + "sass": "^1.59.2", + "spark-md5": "^3.0.2", + "use-debounce": "^9.0.4", + "zustand": "^4.3.8" + }, + "devDependencies": { + "@tauri-apps/cli": "1.5.11", + "@types/node": "^20.11.30", + "@types/react": "^18.2.70", + "@types/react-dom": "^18.2.7", + "@types/react-katex": "^3.0.0", + "@types/spark-md5": "^3.0.4", + "cross-env": "^7.0.3", + "eslint": "^8.49.0", + "eslint-config-next": "13.4.19", + "eslint-config-prettier": "^8.8.0", + "eslint-plugin-prettier": "^5.1.3", + "husky": "^8.0.0", + "lint-staged": "^13.2.2", + "prettier": "^3.0.2", + "tsx": "^4.16.0", + "typescript": "5.2.2", + "watch": "^1.0.2", + "webpack": "^5.88.1" + }, + "resolutions": { + "lint-staged/yaml": "^2.2.2" + }, + "packageManager": "yarn@1.22.19" } diff --git a/yarn.lock b/yarn.lock index 685abb04ed7..c323a5c38db 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1617,11 +1617,6 @@ resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad" integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA== -"@types/crypto-js@^4.2.2": - version "4.2.2" - resolved "https://registry.npmmirror.com/@types/crypto-js/-/crypto-js-4.2.2.tgz#771c4a768d94eb5922cc202a3009558204df0cea" - integrity sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ== - "@types/d3-scale-chromatic@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.0.tgz#103124777e8cdec85b20b51fd3397c682ee1e954" @@ -1702,18 +1697,6 @@ resolved "https://registry.yarnpkg.com/@types/katex/-/katex-0.14.0.tgz#b84c0afc3218069a5ad64fe2a95321881021b5fe" integrity sha512-+2FW2CcT0K3P+JMR8YG846bmDwplKUTsWgT2ENwdQ1UdVfRk3GQrh6Mi4sTopy30gI8Uau5CEqHTDZ6YvWIUPA== -"@types/lodash-es@^4.17.12": - version "4.17.12" - resolved "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz#65f6d1e5f80539aa7cfbfc962de5def0cf4f341b" - integrity sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ== - dependencies: - "@types/lodash" "*" - -"@types/lodash@*": - version "4.17.6" - resolved "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.6.tgz#193ced6a40c8006cfc1ca3f4553444fb38f0e543" - integrity sha512-OpXEVoCKSS3lQqjx9GGGOapBeuW5eUboYHRlHP9urXPX25IKZ6AnP5ZRxtVf63iieUbsHxLn8NQ5Nlftc6yzAA== - "@types/mdast@^3.0.0": version "3.0.11" resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.11.tgz#dc130f7e7d9306124286f6d6cee40cf4d14a3dc0" @@ -2464,11 +2447,6 @@ cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" -crypto-js@^4.2.0: - version "4.2.0" - resolved "https://registry.npmmirror.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631" - integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q== - css-box-model@^1.2.1: version "1.2.1" resolved "https://registry.npmmirror.com/css-box-model/-/css-box-model-1.2.1.tgz#59951d3b81fd6b2074a62d49444415b0d2b4d7c1" From f85ec95877b7aaae52b62d248c397862f087fbce Mon Sep 17 00:00:00 2001 From: lloydzhou Date: Thu, 1 Aug 2024 15:33:48 +0800 Subject: [PATCH 5/9] hotfix for tencent sign --- app/api/tencent/[...path]/route.ts | 135 ++++------------------------- app/client/platforms/tencent.ts | 50 +++++++---- app/components/settings.tsx | 53 +++++++++++ app/locales/cn.ts | 16 ++++ app/locales/en.ts | 16 ++++ app/store/access.ts | 6 +- 6 files changed, 141 insertions(+), 135 deletions(-) diff --git a/app/api/tencent/[...path]/route.ts b/app/api/tencent/[...path]/route.ts index 1a5d42f85f6..8a292d432e2 100644 --- a/app/api/tencent/[...path]/route.ts +++ b/app/api/tencent/[...path]/route.ts @@ -1,3 +1,4 @@ +"use server"; import { getServerSideConfig } from "@/app/config/server"; import { TENCENT_BASE_URL, @@ -10,11 +11,7 @@ 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 CryptoJS from "crypto-js"; -import mapKeys from "lodash-es/mapKeys"; -import mapValues from "lodash-es/mapValues"; -import isArray from "lodash-es/isArray"; -import isObject from "lodash-es/isObject"; +import * as crypto from "node:crypto"; const serverConfig = getServerSideConfig(); @@ -47,27 +44,6 @@ async function handle( export const GET = handle; export const POST = handle; -export const runtime = "edge"; -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(); @@ -99,63 +75,22 @@ async function request(req: NextRequest) { const fetchUrl = `${baseUrl}${path}`; - let body = null; - if (req.body) { - const bodyText = await req.text(); - console.log( - "Dogtiti ~ request ~ capitalizeKeys(JSON.parse(bodyText):", - capitalizeKeys(JSON.parse(bodyText)), - ); - body = JSON.stringify(capitalizeKeys(JSON.parse(bodyText))); - } - + const body = await req.text(); const fetchOptions: RequestInit = { headers: { ...getHeader(body), }, method: req.method, - body: '{"Model":"hunyuan-pro","Messages":[{"Role":"user","Content":"你好"}]}', // FIXME + body, redirect: "manual", // @ts-ignore duplex: "half", signal: controller.signal, }; - // #1815 try to refuse some request to some models - if (serverConfig.customModels && req.body) { - try { - const clonedBody = await req.text(); - fetchOptions.body = clonedBody; - - const jsonBody = JSON.parse(clonedBody) as { model?: string }; - - // not undefined and is false - if ( - isModelAvailableInServer( - serverConfig.customModels, - jsonBody?.model as string, - ServiceProvider.Tencent as string, - ) - ) { - return NextResponse.json( - { - error: true, - message: `you are not allowed to use ${jsonBody?.model} model`, - }, - { - status: 403, - }, - ); - } - } catch (e) { - console.error(`[Tencent] filter`, e); - } - } - console.log("[Tencent request]", fetchOptions.headers, req.method); try { const res = await fetch(fetchUrl, fetchOptions); - console.log("[Tencent response]", res.status, " ", res.headers, res.url); // to prevent browser prompt for credentials const newHeaders = new Headers(res.headers); newHeaders.delete("www-authenticate"); @@ -172,45 +107,16 @@ async function request(req: NextRequest) { } } -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.charAt(0).toUpperCase() + key.slice(1), - ), - capitalizeKeys, - ); - } else { - return obj; - } -} - // 使用 SHA-256 和 secret 进行 HMAC 加密 -function sha256(message: any, secret = "", encoding = "hex") { - const hmac = CryptoJS.HmacSHA256(message, secret); - if (encoding === "hex") { - return hmac.toString(CryptoJS.enc.Hex); - } else if (encoding === "base64") { - return hmac.toString(CryptoJS.enc.Base64); - } else { - return hmac.toString(); - } +function sha256(message: any, secret = "", encoding?: string) { + return crypto.createHmac("sha256", secret).update(message).digest(encoding); } // 使用 SHA-256 进行哈希 function getHash(message: any, encoding = "hex") { - const hash = CryptoJS.SHA256(message); - if (encoding === "hex") { - return hash.toString(CryptoJS.enc.Hex); - } else if (encoding === "base64") { - return hash.toString(CryptoJS.enc.Base64); - } else { - return hash.toString(); - } + return crypto.createHash("sha256").update(message).digest(encoding); } + function getDate(timestamp: number) { const date = new Date(timestamp * 1000); const year = date.getUTCFullYear(); @@ -238,10 +144,11 @@ function getHeader(payload: any) { const hashedRequestPayload = getHash(payload); const httpRequestMethod = "POST"; + const contentType = "application/json"; const canonicalUri = "/"; const canonicalQueryString = ""; const canonicalHeaders = - "content-type:application/json; charset=utf-8\n" + + `content-type:${contentType}\n` + "host:" + endpoint + "\n" + @@ -250,18 +157,14 @@ function getHeader(payload: any) { "\n"; const signedHeaders = "content-type;host;x-tc-action"; - const canonicalRequest = - httpRequestMethod + - "\n" + - canonicalUri + - "\n" + - canonicalQueryString + - "\n" + - canonicalHeaders + - "\n" + - signedHeaders + - "\n" + - hashedRequestPayload; + const canonicalRequest = [ + httpRequestMethod, + canonicalUri, + canonicalQueryString, + canonicalHeaders, + signedHeaders, + hashedRequestPayload, + ].join("\n"); // ************* 步骤 2:拼接待签名字符串 ************* const algorithm = "TC3-HMAC-SHA256"; @@ -299,7 +202,7 @@ function getHeader(payload: any) { return { Authorization: authorization, - "Content-Type": "application/json; charset=utf-8", + "Content-Type": contentType, Host: endpoint, "X-TC-Action": action, "X-TC-Timestamp": timestamp.toString(), diff --git a/app/client/platforms/tencent.ts b/app/client/platforms/tencent.ts index d812c5a82d5..82ecd316484 100644 --- a/app/client/platforms/tencent.ts +++ b/app/client/platforms/tencent.ts @@ -22,6 +22,10 @@ import { 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; @@ -33,17 +37,29 @@ export interface OpenAIListModelResponse { } interface RequestPayload { - messages: { - role: "system" | "user" | "assistant"; - content: string | MultimodalContent[]; + Messages: { + Role: "system" | "user" | "assistant"; + Content: string | MultimodalContent[]; }[]; - stream?: boolean; - model: string; - temperature: number; - presence_penalty: number; - frequency_penalty: number; - top_p: number; - max_tokens?: number; + 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 { @@ -76,7 +92,7 @@ export class HunyuanApi implements LLMApi { } extractMessage(res: any) { - return res.choices?.at(0)?.message?.content ?? ""; + return res.Choices?.at(0)?.Message?.Content ?? ""; } async chat(options: ChatOptions) { @@ -94,15 +110,13 @@ export class HunyuanApi implements LLMApi { }, }; - const requestPayload: RequestPayload = { + const requestPayload: RequestPayload = capitalizeKeys({ messages, stream: options.config.stream, model: modelConfig.model, temperature: modelConfig.temperature, - presence_penalty: modelConfig.presence_penalty, - frequency_penalty: modelConfig.frequency_penalty, top_p: modelConfig.top_p, - }; + }); console.log("[Request] Tencent payload: ", requestPayload); @@ -213,10 +227,10 @@ export class HunyuanApi implements LLMApi { const text = msg.data; try { const json = JSON.parse(text); - const choices = json.choices as Array<{ - delta: { content: string }; + const choices = json.Choices as Array<{ + Delta: { Content: string }; }>; - const delta = choices[0]?.delta?.content; + const delta = choices[0]?.Delta?.Content; if (delta) { remainText += delta; } diff --git a/app/components/settings.tsx b/app/components/settings.tsx index bde3a792ae7..086d36c99d2 100644 --- a/app/components/settings.tsx +++ b/app/components/settings.tsx @@ -54,6 +54,7 @@ import { Anthropic, Azure, Baidu, + Tencent, ByteDance, Alibaba, Google, @@ -964,6 +965,57 @@ export function Settings() { ); + const tencentConfigComponent = accessStore.provider === + ServiceProvider.Tencent && ( + <> + + + accessStore.update( + (access) => (access.tencentUrl = e.currentTarget.value), + ) + } + > + + + { + accessStore.update( + (access) => (access.tencentApiKey = e.currentTarget.value), + ); + }} + /> + + + { + accessStore.update( + (access) => (access.tencentSecretKey = e.currentTarget.value), + ); + }} + /> + + + ); + const byteDanceConfigComponent = accessStore.provider === ServiceProvider.ByteDance && ( <> @@ -1364,6 +1416,7 @@ export function Settings() { {baiduConfigComponent} {byteDanceConfigComponent} {alibabaConfigComponent} + {tencentConfigComponent} {stabilityConfigComponent} )} diff --git a/app/locales/cn.ts b/app/locales/cn.ts index cae41bfeeac..3127ac40b1e 100644 --- a/app/locales/cn.ts +++ b/app/locales/cn.ts @@ -371,6 +371,22 @@ const cn = { SubTitle: "不支持自定义前往.env配置", }, }, + Tencent: { + ApiKey: { + Title: "API Key", + SubTitle: "使用自定义腾讯云API Key", + Placeholder: "Tencent API Key", + }, + SecretKey: { + Title: "Secret Key", + SubTitle: "使用自定义腾讯云Secret Key", + Placeholder: "Tencent Secret Key", + }, + Endpoint: { + Title: "接口地址", + SubTitle: "不支持自定义前往.env配置", + }, + }, ByteDance: { ApiKey: { Title: "接口密钥", diff --git a/app/locales/en.ts b/app/locales/en.ts index bfb383e8f0f..0dc218031bf 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -354,6 +354,22 @@ const en: LocaleType = { SubTitle: "not supported, configure in .env", }, }, + Tencent: { + ApiKey: { + Title: "Tencent API Key", + SubTitle: "Use a custom Tencent API Key", + Placeholder: "Tencent API Key", + }, + SecretKey: { + Title: "Tencent Secret Key", + SubTitle: "Use a custom Tencent Secret Key", + Placeholder: "Tencent Secret Key", + }, + Endpoint: { + Title: "Endpoint Address", + SubTitle: "not supported, configure in .env", + }, + }, ByteDance: { ApiKey: { Title: "ByteDance API Key", diff --git a/app/store/access.ts b/app/store/access.ts index 288cf0a286e..af79480f9a0 100644 --- a/app/store/access.ts +++ b/app/store/access.ts @@ -39,6 +39,10 @@ const DEFAULT_ALIBABA_URL = isApp ? DEFAULT_API_HOST + "/api/proxy/alibaba" : ApiPath.Alibaba; +const DEFAULT_TENCENT_URL = isApp + ? DEFAULT_API_HOST + "/api/proxy/tencent" + : ApiPath.Tencent; + const DEFAULT_STABILITY_URL = isApp ? DEFAULT_API_HOST + "/api/proxy/stability" : ApiPath.Stability; @@ -87,7 +91,7 @@ const DEFAULT_ACCESS_STATE = { stabilityApiKey: "", // tencent - tencentUrl: "", + tencentUrl: DEFAULT_TENCENT_URL, tencentSecretKey: "", tencentSecretId: "", From e1d6131f13d69aa69194f570ebea036f36932722 Mon Sep 17 00:00:00 2001 From: lloydzhou Date: Thu, 1 Aug 2024 16:49:55 +0800 Subject: [PATCH 6/9] update --- app/api/tencent/[...path]/route.ts | 137 ++++++----------------------- app/client/platforms/tencent.ts | 4 + app/components/settings.tsx | 4 +- app/utils/tencent.ts | 112 +++++++++++++++++++++++ 4 files changed, 146 insertions(+), 111 deletions(-) create mode 100644 app/utils/tencent.ts diff --git a/app/api/tencent/[...path]/route.ts b/app/api/tencent/[...path]/route.ts index 8a292d432e2..216f941b6a8 100644 --- a/app/api/tencent/[...path]/route.ts +++ b/app/api/tencent/[...path]/route.ts @@ -1,4 +1,3 @@ -"use server"; import { getServerSideConfig } from "@/app/config/server"; import { TENCENT_BASE_URL, @@ -11,7 +10,7 @@ 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 * as crypto from "node:crypto"; +import { getHeader } from "@/app/utils/tencent"; const serverConfig = getServerSideConfig(); @@ -44,6 +43,27 @@ async function handle( 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(); @@ -76,10 +96,13 @@ async function request(req: NextRequest) { const fetchUrl = `${baseUrl}${path}`; const body = await req.text(); + const headers = await getHeader( + body, + serverConfig.tencentSecretId as string, + serverConfig.tencentSecretKey as string, + ); const fetchOptions: RequestInit = { - headers: { - ...getHeader(body), - }, + headers, method: req.method, body, redirect: "manual", @@ -106,107 +129,3 @@ async function request(req: NextRequest) { clearTimeout(timeoutId); } } - -// 使用 SHA-256 和 secret 进行 HMAC 加密 -function sha256(message: any, secret = "", encoding?: string) { - return crypto.createHmac("sha256", secret).update(message).digest(encoding); -} - -// 使用 SHA-256 进行哈希 -function getHash(message: any, encoding = "hex") { - return crypto.createHash("sha256").update(message).digest(encoding); -} - -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}`; -} - -function getHeader(payload: any) { - // https://cloud.tencent.com/document/api/1729/105701 - // 密钥参数 - const SECRET_ID = serverConfig.tencentSecretId; - const SECRET_KEY = serverConfig.tencentSecretKey; - - 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, - }; -} diff --git a/app/client/platforms/tencent.ts b/app/client/platforms/tencent.ts index 82ecd316484..119006770ce 100644 --- a/app/client/platforms/tencent.ts +++ b/app/client/platforms/tencent.ts @@ -22,9 +22,13 @@ import { import { prettyObject } from "@/app/utils/format"; import { getClientConfig } from "@/app/config/client"; import { getMessageTextContent, isVisionModel } from "@/app/utils"; +// @ts-ignore import mapKeys from "lodash-es/mapKeys"; +// @ts-ignore import mapValues from "lodash-es/mapValues"; +// @ts-ignore import isArray from "lodash-es/isArray"; +// @ts-ignore import isObject from "lodash-es/isObject"; export interface OpenAIListModelResponse { diff --git a/app/components/settings.tsx b/app/components/settings.tsx index 1a8c43c06d8..3197812259e 100644 --- a/app/components/settings.tsx +++ b/app/components/settings.tsx @@ -989,12 +989,12 @@ export function Settings() { subTitle={Locale.Settings.Access.Tencent.ApiKey.SubTitle} > { accessStore.update( - (access) => (access.tencentApiKey = e.currentTarget.value), + (access) => (access.tencentSecretId = e.currentTarget.value), ); }} /> diff --git a/app/utils/tencent.ts b/app/utils/tencent.ts new file mode 100644 index 00000000000..d304c628478 --- /dev/null +++ b/app/utils/tencent.ts @@ -0,0 +1,112 @@ +"use server"; +import * as crypto from "node:crypto"; +// 使用 SHA-256 和 secret 进行 HMAC 加密 +function sha256(message: any, secret = "", encoding?: string) { + return crypto + .createHmac("sha256", secret) + .update(message) + .digest(encoding as any); +} + +// 使用 SHA-256 进行哈希 +function getHash(message: any, encoding = "hex") { + return crypto + .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, + }; +} From c359b92ddc32603a9937d0d4f6231c4b660d0da0 Mon Sep 17 00:00:00 2001 From: lloydzhou Date: Thu, 1 Aug 2024 18:58:07 +0800 Subject: [PATCH 7/9] update --- app/api/tencent/{[...path] => }/route.ts | 9 +-------- app/client/platforms/tencent.ts | 7 +++---- app/constant.ts | 1 - app/utils/tencent.ts | 9 +++------ 4 files changed, 7 insertions(+), 19 deletions(-) rename app/api/tencent/{[...path] => }/route.ts (91%) diff --git a/app/api/tencent/[...path]/route.ts b/app/api/tencent/route.ts similarity index 91% rename from app/api/tencent/[...path]/route.ts rename to app/api/tencent/route.ts index 216f941b6a8..d506d1016d5 100644 --- a/app/api/tencent/[...path]/route.ts +++ b/app/api/tencent/route.ts @@ -67,12 +67,6 @@ export const preferredRegion = [ async function request(req: NextRequest) { const controller = new AbortController(); - // tencent just use base url or just remove the path - let path = `${req.nextUrl.pathname}`.replaceAll( - ApiPath.Tencent + "/" + Tencent.ChatPath, - "", - ); - let baseUrl = serverConfig.tencentUrl || TENCENT_BASE_URL; if (!baseUrl.startsWith("http")) { @@ -83,7 +77,6 @@ async function request(req: NextRequest) { baseUrl = baseUrl.slice(0, -1); } - console.log("[Proxy] ", path); console.log("[Base Url]", baseUrl); const timeoutId = setTimeout( @@ -93,7 +86,7 @@ async function request(req: NextRequest) { 10 * 60 * 1000, ); - const fetchUrl = `${baseUrl}${path}`; + const fetchUrl = baseUrl; const body = await req.text(); const headers = await getHeader( diff --git a/app/client/platforms/tencent.ts b/app/client/platforms/tencent.ts index 119006770ce..621fede1027 100644 --- a/app/client/platforms/tencent.ts +++ b/app/client/platforms/tencent.ts @@ -91,8 +91,7 @@ export class HunyuanApi implements LLMApi { } console.log("[Proxy Endpoint] ", baseUrl); - - return [baseUrl, path].join("/"); + return baseUrl; } extractMessage(res: any) { @@ -115,11 +114,11 @@ export class HunyuanApi implements LLMApi { }; const requestPayload: RequestPayload = capitalizeKeys({ - messages, - stream: options.config.stream, model: modelConfig.model, + messages, temperature: modelConfig.temperature, top_p: modelConfig.top_p, + stream: options.config.stream, }); console.log("[Request] Tencent payload: ", requestPayload); diff --git a/app/constant.ts b/app/constant.ts index ae57b340de0..5251b5b4fc9 100644 --- a/app/constant.ts +++ b/app/constant.ts @@ -195,7 +195,6 @@ export const Alibaba = { export const Tencent = { ExampleEndpoint: TENCENT_BASE_URL, - ChatPath: "v1/chat/completions", }; export const Moonshot = { diff --git a/app/utils/tencent.ts b/app/utils/tencent.ts index d304c628478..019d330b6ff 100644 --- a/app/utils/tencent.ts +++ b/app/utils/tencent.ts @@ -1,17 +1,14 @@ -"use server"; -import * as crypto from "node:crypto"; +import { createHash, createHmac } from "node:crypto"; // 使用 SHA-256 和 secret 进行 HMAC 加密 function sha256(message: any, secret = "", encoding?: string) { - return crypto - .createHmac("sha256", secret) + return createHmac("sha256", secret) .update(message) .digest(encoding as any); } // 使用 SHA-256 进行哈希 function getHash(message: any, encoding = "hex") { - return crypto - .createHash("sha256") + return createHash("sha256") .update(message) .digest(encoding as any); } From dfc36e5210735ba28c8416796b112f81978027e1 Mon Sep 17 00:00:00 2001 From: lloydzhou Date: Thu, 1 Aug 2024 19:02:40 +0800 Subject: [PATCH 8/9] update --- app/client/platforms/tencent.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/app/client/platforms/tencent.ts b/app/client/platforms/tencent.ts index 621fede1027..67dd9225ce7 100644 --- a/app/client/platforms/tencent.ts +++ b/app/client/platforms/tencent.ts @@ -1,10 +1,5 @@ "use client"; -import { - ApiPath, - DEFAULT_API_HOST, - REQUEST_TIMEOUT_MS, - Tencent, -} from "@/app/constant"; +import { ApiPath, DEFAULT_API_HOST, REQUEST_TIMEOUT_MS } from "@/app/constant"; import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; import { @@ -67,7 +62,7 @@ function capitalizeKeys(obj: any): any { } export class HunyuanApi implements LLMApi { - path(path: string): string { + path(): string { const accessStore = useAccessStore.getState(); let baseUrl = ""; @@ -79,7 +74,7 @@ export class HunyuanApi implements LLMApi { if (baseUrl.length === 0) { const isApp = !!getClientConfig()?.isApp; baseUrl = isApp - ? DEFAULT_API_HOST + "/api/proxy/bytedance" + ? DEFAULT_API_HOST + "/api/proxy/tencent" : ApiPath.Tencent; } @@ -128,7 +123,7 @@ export class HunyuanApi implements LLMApi { options.onController?.(controller); try { - const chatPath = this.path(Tencent.ChatPath); + const chatPath = this.path(); const chatPayload = { method: "POST", body: JSON.stringify(requestPayload), From a17df037afadcf7b61264686e9993555eed44e6f Mon Sep 17 00:00:00 2001 From: Dogtiti <499960698@qq.com> Date: Thu, 1 Aug 2024 23:54:14 +0800 Subject: [PATCH 9/9] feat: add lodash-es --- app/client/platforms/tencent.ts | 4 ---- package.json | 2 ++ yarn.lock | 12 ++++++++++++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/app/client/platforms/tencent.ts b/app/client/platforms/tencent.ts index 67dd9225ce7..e9e49d3f0b0 100644 --- a/app/client/platforms/tencent.ts +++ b/app/client/platforms/tencent.ts @@ -17,13 +17,9 @@ import { import { prettyObject } from "@/app/utils/format"; import { getClientConfig } from "@/app/config/client"; import { getMessageTextContent, isVisionModel } from "@/app/utils"; -// @ts-ignore import mapKeys from "lodash-es/mapKeys"; -// @ts-ignore import mapValues from "lodash-es/mapValues"; -// @ts-ignore import isArray from "lodash-es/isArray"; -// @ts-ignore import isObject from "lodash-es/isObject"; export interface OpenAIListModelResponse { diff --git a/package.json b/package.json index ed5edb04330..4e4acf93061 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "fuse.js": "^7.0.0", "heic2any": "^0.0.4", "html-to-image": "^1.11.11", + "lodash-es": "^4.17.21", "mermaid": "^10.6.1", "nanoid": "^5.0.3", "next": "^14.1.1", @@ -48,6 +49,7 @@ }, "devDependencies": { "@tauri-apps/cli": "1.5.11", + "@types/lodash-es": "^4.17.12", "@types/node": "^20.11.30", "@types/react": "^18.2.70", "@types/react-dom": "^18.2.7", diff --git a/yarn.lock b/yarn.lock index c323a5c38db..d2d92e322e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1697,6 +1697,18 @@ resolved "https://registry.yarnpkg.com/@types/katex/-/katex-0.14.0.tgz#b84c0afc3218069a5ad64fe2a95321881021b5fe" integrity sha512-+2FW2CcT0K3P+JMR8YG846bmDwplKUTsWgT2ENwdQ1UdVfRk3GQrh6Mi4sTopy30gI8Uau5CEqHTDZ6YvWIUPA== +"@types/lodash-es@^4.17.12": + version "4.17.12" + resolved "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz#65f6d1e5f80539aa7cfbfc962de5def0cf4f341b" + integrity sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ== + dependencies: + "@types/lodash" "*" + +"@types/lodash@*": + version "4.17.7" + resolved "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.7.tgz#2f776bcb53adc9e13b2c0dfd493dfcbd7de43612" + integrity sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA== + "@types/mdast@^3.0.0": version "3.0.11" resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.11.tgz#dc130f7e7d9306124286f6d6cee40cf4d14a3dc0"