diff --git a/app/api/anthropic/[...path]/route.ts b/app/api/anthropic/[...path]/route.ts new file mode 100644 index 00000000000..4264893d93e --- /dev/null +++ b/app/api/anthropic/[...path]/route.ts @@ -0,0 +1,189 @@ +import { getServerSideConfig } from "@/app/config/server"; +import { + ANTHROPIC_BASE_URL, + Anthropic, + ApiPath, + DEFAULT_MODELS, + ModelProvider, +} from "@/app/constant"; +import { prettyObject } from "@/app/utils/format"; +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "../../auth"; +import { collectModelTable } from "@/app/utils/model"; + +const ALLOWD_PATH = new Set([Anthropic.ChatPath, Anthropic.ChatPath1]); + +async function handle( + req: NextRequest, + { params }: { params: { path: string[] } }, +) { + console.log("[Anthropic Route] params ", params); + + if (req.method === "OPTIONS") { + return NextResponse.json({ body: "OK" }, { status: 200 }); + } + + const subpath = params.path.join("/"); + + if (!ALLOWD_PATH.has(subpath)) { + console.log("[Anthropic Route] forbidden path ", subpath); + return NextResponse.json( + { + error: true, + msg: "you are not allowed to request " + subpath, + }, + { + status: 403, + }, + ); + } + + const authResult = auth(req, ModelProvider.Claude); + if (authResult.error) { + return NextResponse.json(authResult, { + status: 401, + }); + } + + try { + const response = await request(req); + return response; + } catch (e) { + console.error("[Anthropic] ", 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", +]; + +const serverConfig = getServerSideConfig(); + +async function request(req: NextRequest) { + const controller = new AbortController(); + + let authHeaderName = "x-api-key"; + let authValue = + req.headers.get(authHeaderName) || + req.headers.get("Authorization")?.replaceAll("Bearer ", "").trim() || + serverConfig.anthropicApiKey || + ""; + + let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Anthropic, ""); + + let baseUrl = + serverConfig.anthropicUrl || serverConfig.baseUrl || ANTHROPIC_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", + "Cache-Control": "no-store", + [authHeaderName]: authValue, + "anthropic-version": + req.headers.get("anthropic-version") || + serverConfig.anthropicApiVersion || + Anthropic.Vision, + }, + 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 modelTable = collectModelTable( + DEFAULT_MODELS, + serverConfig.customModels, + ); + const clonedBody = await req.text(); + fetchOptions.body = clonedBody; + + const jsonBody = JSON.parse(clonedBody) as { model?: string }; + + // not undefined and is false + if (modelTable[jsonBody?.model ?? ""].available === false) { + return NextResponse.json( + { + error: true, + message: `you are not allowed to use ${jsonBody?.model} model`, + }, + { + status: 403, + }, + ); + } + } catch (e) { + console.error(`[Anthropic] filter`, e); + } + } + console.log("[Anthropic request]", fetchOptions.headers, req.method); + try { + const res = await fetch(fetchUrl, fetchOptions); + + console.log( + "[Anthropic 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); + } +} diff --git a/app/api/auth.ts b/app/api/auth.ts index 16c8034eb55..b750f2d1731 100644 --- a/app/api/auth.ts +++ b/app/api/auth.ts @@ -57,12 +57,31 @@ export function auth(req: NextRequest, modelProvider: ModelProvider) { if (!apiKey) { const serverConfig = getServerSideConfig(); - const systemApiKey = - modelProvider === ModelProvider.GeminiPro - ? serverConfig.googleApiKey - : serverConfig.isAzure - ? serverConfig.azureApiKey - : serverConfig.apiKey; + // const systemApiKey = + // modelProvider === ModelProvider.GeminiPro + // ? serverConfig.googleApiKey + // : serverConfig.isAzure + // ? serverConfig.azureApiKey + // : serverConfig.apiKey; + + let systemApiKey: string | undefined; + + switch (modelProvider) { + case ModelProvider.GeminiPro: + systemApiKey = serverConfig.googleApiKey; + break; + case ModelProvider.Claude: + systemApiKey = serverConfig.anthropicApiKey; + break; + case ModelProvider.GPT: + default: + if (serverConfig.isAzure) { + systemApiKey = serverConfig.azureApiKey; + } else { + systemApiKey = serverConfig.apiKey; + } + } + if (systemApiKey) { console.log("[Auth] use system api key"); req.headers.set("Authorization", `Bearer ${systemApiKey}`); diff --git a/app/client/api.ts b/app/client/api.ts index c4d548a41b0..7bee546b4f6 100644 --- a/app/client/api.ts +++ b/app/client/api.ts @@ -8,6 +8,7 @@ import { import { ChatMessage, ModelType, useAccessStore, useChatStore } from "../store"; import { ChatGPTApi } from "./platforms/openai"; import { GeminiProApi } from "./platforms/google"; +import { ClaudeApi } from "./platforms/anthropic"; export const ROLES = ["system", "user", "assistant"] as const; export type MessageRole = (typeof ROLES)[number]; @@ -94,11 +95,16 @@ export class ClientApi { public llm: LLMApi; constructor(provider: ModelProvider = ModelProvider.GPT) { - if (provider === ModelProvider.GeminiPro) { - this.llm = new GeminiProApi(); - return; + switch (provider) { + case ModelProvider.GeminiPro: + this.llm = new GeminiProApi(); + break; + case ModelProvider.Claude: + this.llm = new ClaudeApi(); + break; + default: + this.llm = new ChatGPTApi(); } - this.llm = new ChatGPTApi(); } config() {} diff --git a/app/client/platforms/anthropic.ts b/app/client/platforms/anthropic.ts new file mode 100644 index 00000000000..fea3d8654c1 --- /dev/null +++ b/app/client/platforms/anthropic.ts @@ -0,0 +1,404 @@ +import { ACCESS_CODE_PREFIX, Anthropic, ApiPath } from "@/app/constant"; +import { ChatOptions, LLMApi, MultimodalContent } from "../api"; +import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; +import { getClientConfig } from "@/app/config/client"; +import { DEFAULT_API_HOST } from "@/app/constant"; +import { RequestMessage } from "@/app/typing"; +import { + EventStreamContentType, + fetchEventSource, +} from "@fortaine/fetch-event-source"; + +import Locale from "../../locales"; +import { prettyObject } from "@/app/utils/format"; +import { getMessageTextContent, isVisionModel } from "@/app/utils"; + +export type MultiBlockContent = { + type: "image" | "text"; + source?: { + type: string; + media_type: string; + data: string; + }; + text?: string; +}; + +export type AnthropicMessage = { + role: (typeof ClaudeMapper)[keyof typeof ClaudeMapper]; + content: string | MultiBlockContent[]; +}; + +export interface AnthropicChatRequest { + model: string; // The model that will complete your prompt. + messages: AnthropicMessage[]; // The prompt that you want Claude to complete. + max_tokens: number; // The maximum number of tokens to generate before stopping. + stop_sequences?: string[]; // Sequences that will cause the model to stop generating completion text. + temperature?: number; // Amount of randomness injected into the response. + top_p?: number; // Use nucleus sampling. + top_k?: number; // Only sample from the top K options for each subsequent token. + metadata?: object; // An object describing metadata about the request. + stream?: boolean; // Whether to incrementally stream the response using server-sent events. +} + +export interface ChatRequest { + model: string; // The model that will complete your prompt. + prompt: string; // The prompt that you want Claude to complete. + max_tokens_to_sample: number; // The maximum number of tokens to generate before stopping. + stop_sequences?: string[]; // Sequences that will cause the model to stop generating completion text. + temperature?: number; // Amount of randomness injected into the response. + top_p?: number; // Use nucleus sampling. + top_k?: number; // Only sample from the top K options for each subsequent token. + metadata?: object; // An object describing metadata about the request. + stream?: boolean; // Whether to incrementally stream the response using server-sent events. +} + +export interface ChatResponse { + completion: string; + stop_reason: "stop_sequence" | "max_tokens"; + model: string; +} + +export type ChatStreamResponse = ChatResponse & { + stop?: string; + log_id: string; +}; + +const ClaudeMapper = { + assistant: "assistant", + user: "user", + system: "user", +} as const; + +const keys = ["claude-2, claude-instant-1"]; + +export class ClaudeApi implements LLMApi { + extractMessage(res: any) { + console.log("[Response] claude response: ", res); + + return res?.content?.[0]?.text; + } + async chat(options: ChatOptions): Promise { + const visionModel = isVisionModel(options.config.model); + + const accessStore = useAccessStore.getState(); + + const shouldStream = !!options.config.stream; + + const modelConfig = { + ...useAppConfig.getState().modelConfig, + ...useChatStore.getState().currentSession().mask.modelConfig, + ...{ + model: options.config.model, + }, + }; + + const messages = [...options.messages]; + + const keys = ["system", "user"]; + + // roles must alternate between "user" and "assistant" in claude, so add a fake assistant message between two user messages + for (let i = 0; i < messages.length - 1; i++) { + const message = messages[i]; + const nextMessage = messages[i + 1]; + + if (keys.includes(message.role) && keys.includes(nextMessage.role)) { + messages[i] = [ + message, + { + role: "assistant", + content: ";", + }, + ] as any; + } + } + + const prompt = messages + .flat() + .filter((v) => { + if (!v.content) return false; + if (typeof v.content === "string" && !v.content.trim()) return false; + return true; + }) + .map((v) => { + const { role, content } = v; + const insideRole = ClaudeMapper[role] ?? "user"; + + if (!visionModel || typeof content === "string") { + return { + role: insideRole, + content: getMessageTextContent(v), + }; + } + return { + role: insideRole, + content: content + .filter((v) => v.image_url || v.text) + .map(({ type, text, image_url }) => { + if (type === "text") { + return { + type, + text: text!, + }; + } + const { url = "" } = image_url || {}; + const colonIndex = url.indexOf(":"); + const semicolonIndex = url.indexOf(";"); + const comma = url.indexOf(","); + + const mimeType = url.slice(colonIndex + 1, semicolonIndex); + const encodeType = url.slice(semicolonIndex + 1, comma); + const data = url.slice(comma + 1); + + return { + type: "image" as const, + source: { + type: encodeType, + media_type: mimeType, + data, + }, + }; + }), + }; + }); + + const requestBody: AnthropicChatRequest = { + messages: prompt, + stream: shouldStream, + + model: modelConfig.model, + max_tokens: modelConfig.max_tokens, + temperature: modelConfig.temperature, + top_p: modelConfig.top_p, + // top_k: modelConfig.top_k, + top_k: 5, + }; + + const path = this.path(Anthropic.ChatPath); + + const controller = new AbortController(); + options.onController?.(controller); + + const payload = { + method: "POST", + body: JSON.stringify(requestBody), + signal: controller.signal, + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "x-api-key": accessStore.anthropicApiKey, + "anthropic-version": accessStore.anthropicApiVersion, + Authorization: getAuthKey(accessStore.anthropicApiKey), + }, + }; + + if (shouldStream) { + try { + const context = { + text: "", + finished: false, + }; + + const finish = () => { + if (!context.finished) { + options.onFinish(context.text); + context.finished = true; + } + }; + + controller.signal.onabort = finish; + fetchEventSource(path, { + ...payload, + async onopen(res) { + const contentType = res.headers.get("content-type"); + console.log("response content type: ", contentType); + + if (contentType?.startsWith("text/plain")) { + context.text = await res.clone().text(); + return finish(); + } + + if ( + !res.ok || + !res.headers + .get("content-type") + ?.startsWith(EventStreamContentType) || + res.status !== 200 + ) { + const responseTexts = [context.text]; + let extraInfo = await res.clone().text(); + try { + const resJson = await res.clone().json(); + extraInfo = prettyObject(resJson); + } catch {} + + if (res.status === 401) { + responseTexts.push(Locale.Error.Unauthorized); + } + + if (extraInfo) { + responseTexts.push(extraInfo); + } + + context.text = responseTexts.join("\n\n"); + + return finish(); + } + }, + onmessage(msg) { + let chunkJson: + | undefined + | { + type: "content_block_delta" | "content_block_stop"; + delta?: { + type: "text_delta"; + text: string; + }; + index: number; + }; + try { + chunkJson = JSON.parse(msg.data); + } catch (e) { + console.error("[Response] parse error", msg.data); + } + + if (!chunkJson || chunkJson.type === "content_block_stop") { + return finish(); + } + + const { delta } = chunkJson; + if (delta?.text) { + context.text += delta.text; + options.onUpdate?.(context.text, delta.text); + } + }, + onclose() { + finish(); + }, + onerror(e) { + options.onError?.(e); + throw e; + }, + openWhenHidden: true, + }); + } catch (e) { + console.error("failed to chat", e); + options.onError?.(e as Error); + } + } else { + try { + controller.signal.onabort = () => options.onFinish(""); + + const res = await fetch(path, payload); + const resJson = await res.json(); + + const message = this.extractMessage(resJson); + options.onFinish(message); + } catch (e) { + console.error("failed to chat", e); + options.onError?.(e as Error); + } + } + } + async usage() { + return { + used: 0, + total: 0, + }; + } + async models() { + // const provider = { + // id: "anthropic", + // providerName: "Anthropic", + // providerType: "anthropic", + // }; + + return [ + // { + // name: "claude-instant-1.2", + // available: true, + // provider, + // }, + // { + // name: "claude-2.0", + // available: true, + // provider, + // }, + // { + // name: "claude-2.1", + // available: true, + // provider, + // }, + // { + // name: "claude-3-opus-20240229", + // available: true, + // provider, + // }, + // { + // name: "claude-3-sonnet-20240229", + // available: true, + // provider, + // }, + // { + // name: "claude-3-haiku-20240307", + // available: true, + // provider, + // }, + ]; + } + path(path: string): string { + const accessStore = useAccessStore.getState(); + + let baseUrl: string = accessStore.anthropicUrl; + + // if endpoint is empty, use default endpoint + if (baseUrl.trim().length === 0) { + const isApp = !!getClientConfig()?.isApp; + + baseUrl = isApp + ? DEFAULT_API_HOST + "/api/proxy/anthropic" + : ApiPath.Anthropic; + } + + if (!baseUrl.startsWith("http") && !baseUrl.startsWith("/api")) { + baseUrl = "https://" + baseUrl; + } + + baseUrl = trimEnd(baseUrl, "/"); + + return `${baseUrl}/${path}`; + } +} + +function trimEnd(s: string, end = " ") { + if (end.length === 0) return s; + + while (s.endsWith(end)) { + s = s.slice(0, -end.length); + } + + return s; +} + +function bearer(value: string) { + return `Bearer ${value.trim()}`; +} + +function getAuthKey(apiKey = "") { + const accessStore = useAccessStore.getState(); + const isApp = !!getClientConfig()?.isApp; + let authKey = ""; + + if (apiKey) { + // use user's api key first + authKey = bearer(apiKey); + } else if ( + accessStore.enabledAccessControl() && + !isApp && + !!accessStore.accessCode + ) { + // or use access code + authKey = bearer(ACCESS_CODE_PREFIX + accessStore.accessCode); + } + + return authKey; +} diff --git a/app/client/platforms/openai.ts b/app/client/platforms/openai.ts index 408ee704e1c..7652ba0f2f9 100644 --- a/app/client/platforms/openai.ts +++ b/app/client/platforms/openai.ts @@ -40,6 +40,20 @@ export interface OpenAIListModelResponse { }>; } +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 ChatGPTApi implements LLMApi { private disableListModels = true; @@ -98,7 +112,7 @@ export class ChatGPTApi implements LLMApi { }, }; - const requestPayload = { + const requestPayload: RequestPayload = { messages, stream: options.config.stream, model: modelConfig.model, @@ -112,12 +126,7 @@ export class ChatGPTApi implements LLMApi { // add max_tokens to vision model if (visionModel) { - Object.defineProperty(requestPayload, "max_tokens", { - enumerable: true, - configurable: true, - writable: true, - value: modelConfig.max_tokens, - }); + requestPayload["max_tokens"] = Math.max(modelConfig.max_tokens, 4000); } console.log("[Request] openai payload: ", requestPayload); @@ -229,7 +238,9 @@ export class ChatGPTApi 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 textmoderation = json?.prompt_filter_results; @@ -237,9 +248,17 @@ export class ChatGPTApi implements LLMApi { remainText += delta; } - if (textmoderation && textmoderation.length > 0 && ServiceProvider.Azure) { - const contentFilterResults = textmoderation[0]?.content_filter_results; - console.log(`[${ServiceProvider.Azure}] [Text Moderation] flagged categories result:`, contentFilterResults); + if ( + textmoderation && + textmoderation.length > 0 && + ServiceProvider.Azure + ) { + const contentFilterResults = + textmoderation[0]?.content_filter_results; + console.log( + `[${ServiceProvider.Azure}] [Text Moderation] flagged categories result:`, + contentFilterResults, + ); } } catch (e) { console.error("[Request] parse error", text, msg); diff --git a/app/components/exporter.tsx b/app/components/exporter.tsx index 3a8a9f63fb4..f3f08572154 100644 --- a/app/components/exporter.tsx +++ b/app/components/exporter.tsx @@ -315,6 +315,8 @@ export function PreviewActions(props: { var api: ClientApi; if (config.modelConfig.model.startsWith("gemini")) { api = new ClientApi(ModelProvider.GeminiPro); + } else if (config.modelConfig.model.startsWith("claude")) { + api = new ClientApi(ModelProvider.Claude); } else { api = new ClientApi(ModelProvider.GPT); } diff --git a/app/components/home.tsx b/app/components/home.tsx index 8386ba144b9..26bb3a44c19 100644 --- a/app/components/home.tsx +++ b/app/components/home.tsx @@ -173,6 +173,8 @@ export function useLoadData() { var api: ClientApi; if (config.modelConfig.model.startsWith("gemini")) { api = new ClientApi(ModelProvider.GeminiPro); + } else if (config.modelConfig.model.startsWith("claude")) { + api = new ClientApi(ModelProvider.Claude); } else { api = new ClientApi(ModelProvider.GPT); } diff --git a/app/components/markdown.tsx b/app/components/markdown.tsx index 7c70fe1a5ac..2b036051a95 100644 --- a/app/components/markdown.tsx +++ b/app/components/markdown.tsx @@ -135,10 +135,10 @@ function escapeBrackets(text: string) { } function _MarkDownContent(props: { content: string }) { - const escapedContent = useMemo( - () => escapeBrackets(escapeDollarNumber(props.content)), - [props.content], - ); + const escapedContent = useMemo(() => { + console.log("================", props.content); + return escapeBrackets(escapeDollarNumber(props.content)); + }, [props.content]); return ( - {accessStore.provider === "OpenAI" ? ( + {accessStore.provider === ServiceProvider.OpenAI && ( <> - ) : accessStore.provider === "Azure" ? ( + )} + {accessStore.provider === ServiceProvider.Azure && ( <> - ) : accessStore.provider === "Google" ? ( + )} + {accessStore.provider === ServiceProvider.Google && ( <> - ) : null} + )} + {accessStore.provider === ServiceProvider.Anthropic && ( + <> + + + accessStore.update( + (access) => + (access.anthropicUrl = e.currentTarget.value), + ) + } + > + + + { + accessStore.update( + (access) => + (access.anthropicApiKey = + e.currentTarget.value), + ); + }} + /> + + + + accessStore.update( + (access) => + (access.anthropicApiVersion = + e.currentTarget.value), + ) + } + > + + + )} )} diff --git a/app/config/server.ts b/app/config/server.ts index dffc2563e35..d18e4a1a694 100644 --- a/app/config/server.ts +++ b/app/config/server.ts @@ -69,6 +69,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 apiKeyEnvVar = process.env.OPENAI_API_KEY ?? ""; const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim()); @@ -92,6 +93,11 @@ export const getServerSideConfig = () => { googleApiKey: process.env.GOOGLE_API_KEY, googleUrl: process.env.GOOGLE_URL, + isAnthropic, + anthropicApiKey: process.env.ANTHROPIC_API_KEY, + anthropicApiVersion: process.env.ANTHROPIC_API_VERSION, + anthropicUrl: process.env.ANTHROPIC_URL, + gtmId: process.env.GTM_ID, needCode: ACCESS_CODES.size > 0, diff --git a/app/constant.ts b/app/constant.ts index 9041706874f..b5d57612ab6 100644 --- a/app/constant.ts +++ b/app/constant.ts @@ -10,6 +10,7 @@ export const RUNTIME_CONFIG_DOM = "danger-runtime-config"; export const DEFAULT_API_HOST = "https://api.nextchat.dev"; export const OPENAI_BASE_URL = "https://api.openai.com"; +export const ANTHROPIC_BASE_URL = "https://api.anthropic.com"; export const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/"; @@ -25,6 +26,7 @@ export enum Path { export enum ApiPath { Cors = "", OpenAI = "/api/openai", + Anthropic = "/api/anthropic", } export enum SlotID { @@ -67,13 +69,22 @@ export enum ServiceProvider { OpenAI = "OpenAI", Azure = "Azure", Google = "Google", + Anthropic = "Anthropic", } export enum ModelProvider { GPT = "GPT", GeminiPro = "GeminiPro", + Claude = "Claude", } +export const Anthropic = { + ChatPath: "v1/messages", + ChatPath1: "v1/complete", + ExampleEndpoint: "https://api.anthropic.com", + Vision: "2023-06-01", +}; + export const OpenaiPath = { ChatPath: "v1/chat/completions", UsagePath: "dashboard/billing/usage", @@ -94,12 +105,20 @@ export const Google = { }; 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}}. +// Knowledge cutoff: {{cutoff}} +// Current model: {{model}} +// Current time: {{time}} +// Latex inline: $x^2$ +// Latex block: $$e=mc^2$$ +// `; export const DEFAULT_SYSTEM_TEMPLATE = ` You are ChatGPT, a large language model trained by {{ServiceProvider}}. Knowledge cutoff: {{cutoff}} Current model: {{model}} Current time: {{time}} -Latex inline: $x^2$ +Latex inline: \(x^2\) Latex block: $$e=mc^2$$ `; @@ -289,6 +308,60 @@ export const DEFAULT_MODELS = [ providerType: "google", }, }, + { + name: "claude-instant-1.2", + available: true, + provider: { + id: "anthropic", + providerName: "Anthropic", + providerType: "anthropic", + }, + }, + { + name: "claude-2.0", + available: true, + provider: { + id: "anthropic", + providerName: "Anthropic", + providerType: "anthropic", + }, + }, + { + name: "claude-2.1", + available: true, + provider: { + id: "anthropic", + providerName: "Anthropic", + providerType: "anthropic", + }, + }, + { + name: "claude-3-opus-20240229", + available: true, + provider: { + id: "anthropic", + providerName: "Anthropic", + providerType: "anthropic", + }, + }, + { + name: "claude-3-sonnet-20240229", + available: true, + provider: { + id: "anthropic", + providerName: "Anthropic", + providerType: "anthropic", + }, + }, + { + name: "claude-3-haiku-20240307", + available: true, + provider: { + id: "anthropic", + providerName: "Anthropic", + providerType: "anthropic", + }, + }, ] as const; export const CHAT_PAGE_SIZE = 15; diff --git a/app/locales/cn.ts b/app/locales/cn.ts index 5d0c284283e..2ff94e32d43 100644 --- a/app/locales/cn.ts +++ b/app/locales/cn.ts @@ -313,6 +313,23 @@ const cn = { SubTitle: "选择指定的部分版本", }, }, + Anthropic: { + ApiKey: { + Title: "接口密钥", + SubTitle: "使用自定义 Anthropic Key 绕过密码访问限制", + Placeholder: "Anthropic API Key", + }, + + Endpoint: { + Title: "接口地址", + SubTitle: "样例:", + }, + + ApiVerion: { + Title: "接口版本 (claude api version)", + SubTitle: "选择一个特定的 API 版本输入", + }, + }, Google: { ApiKey: { Title: "API 密钥", diff --git a/app/locales/en.ts b/app/locales/en.ts index 79a91d7ccd5..59636db7b3f 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -316,6 +316,24 @@ const en: LocaleType = { SubTitle: "Check your api version from azure console", }, }, + Anthropic: { + ApiKey: { + Title: "Anthropic API Key", + SubTitle: + "Use a custom Anthropic Key to bypass password access restrictions", + Placeholder: "Anthropic API Key", + }, + + Endpoint: { + Title: "Endpoint Address", + SubTitle: "Example:", + }, + + ApiVerion: { + Title: "API Version (claude api version)", + SubTitle: "Select and input a specific API version", + }, + }, CustomModel: { Title: "Custom Models", SubTitle: "Custom model options, seperated by comma", diff --git a/app/locales/pt.ts b/app/locales/pt.ts index 85226ed507d..8151b7aa4ac 100644 --- a/app/locales/pt.ts +++ b/app/locales/pt.ts @@ -316,6 +316,23 @@ const pt: PartialLocaleType = { SubTitle: "Verifique sua versão API do console Azure", }, }, + Anthropic: { + ApiKey: { + Title: "Chave API Anthropic", + SubTitle: "Verifique sua chave API do console Anthropic", + Placeholder: "Chave API Anthropic", + }, + + Endpoint: { + Title: "Endpoint Address", + SubTitle: "Exemplo: ", + }, + + ApiVerion: { + Title: "Versão API (Versão api claude)", + SubTitle: "Verifique sua versão API do console Anthropic", + }, + }, CustomModel: { Title: "Modelos Personalizados", SubTitle: "Opções de modelo personalizado, separados por vírgula", diff --git a/app/locales/sk.ts b/app/locales/sk.ts index 025519e775b..a97b7175c24 100644 --- a/app/locales/sk.ts +++ b/app/locales/sk.ts @@ -317,6 +317,23 @@ const sk: PartialLocaleType = { SubTitle: "Skontrolujte svoju verziu API v Azure konzole", }, }, + Anthropic: { + ApiKey: { + Title: "API kľúč Anthropic", + SubTitle: "Skontrolujte svoj API kľúč v Anthropic konzole", + Placeholder: "API kľúč Anthropic", + }, + + Endpoint: { + Title: "Adresa koncového bodu", + SubTitle: "Príklad:", + }, + + ApiVerion: { + Title: "Verzia API (claude verzia API)", + SubTitle: "Vyberte špecifickú verziu časti", + }, + }, CustomModel: { Title: "Vlastné modely", SubTitle: "Možnosti vlastného modelu, oddelené čiarkou", diff --git a/app/locales/tw.ts b/app/locales/tw.ts index b20ff6c8019..96811ae7e40 100644 --- a/app/locales/tw.ts +++ b/app/locales/tw.ts @@ -314,6 +314,23 @@ const tw = { SubTitle: "選擇指定的部分版本", }, }, + Anthropic: { + ApiKey: { + Title: "API 密鑰", + SubTitle: "從 Anthropic AI 獲取您的 API 密鑰", + Placeholder: "Anthropic API Key", + }, + + Endpoint: { + Title: "終端地址", + SubTitle: "示例:", + }, + + ApiVerion: { + Title: "API 版本 (claude api version)", + SubTitle: "選擇一個特定的 API 版本输入", + }, + }, Google: { ApiKey: { Title: "API 密鑰", @@ -467,12 +484,12 @@ const tw = { type DeepPartial = T extends object ? { - [P in keyof T]?: DeepPartial; - } + [P in keyof T]?: DeepPartial; + } : T; export type LocaleType = typeof tw; export type PartialLocaleType = DeepPartial; export default tw; -// Translated by @chunkiuuu, feel free the submit new pr if there are typo/incorrect translations :D \ No newline at end of file +// Translated by @chunkiuuu, feel free the submit new pr if there are typo/incorrect translations :D diff --git a/app/store/access.ts b/app/store/access.ts index 6884e71e3bf..16366640257 100644 --- a/app/store/access.ts +++ b/app/store/access.ts @@ -36,6 +36,11 @@ const DEFAULT_ACCESS_STATE = { googleApiKey: "", googleApiVersion: "v1", + // anthropic + anthropicApiKey: "", + anthropicApiVersion: "2023-06-01", + anthropicUrl: "", + // server config needCode: true, hideUserApiKey: false, @@ -67,6 +72,10 @@ export const useAccessStore = createPersistStore( return ensure(get(), ["googleApiKey"]); }, + isValidAnthropic() { + return ensure(get(), ["anthropicApiKey"]); + }, + isAuthorized() { this.fetch(); @@ -75,6 +84,7 @@ export const useAccessStore = createPersistStore( this.isValidOpenAI() || this.isValidAzure() || this.isValidGoogle() || + this.isValidAnthropic() || !this.enabledAccessControl() || (this.enabledAccessControl() && ensure(get(), ["accessCode"])) ); diff --git a/app/store/chat.ts b/app/store/chat.ts index 029c12a74be..53ec11dbf6b 100644 --- a/app/store/chat.ts +++ b/app/store/chat.ts @@ -126,6 +126,11 @@ function fillTemplateWith(input: string, modelConfig: ModelConfig) { let output = modelConfig.template ?? DEFAULT_INPUT_TEMPLATE; + // remove duplicate + if (input.startsWith(output)) { + output = ""; + } + // must contains {{input}} const inputVar = "{{input}}"; if (!output.includes(inputVar)) { @@ -348,6 +353,8 @@ export const useChatStore = createPersistStore( var api: ClientApi; if (modelConfig.model.startsWith("gemini")) { api = new ClientApi(ModelProvider.GeminiPro); + } else if (modelConfig.model.startsWith("claude")) { + api = new ClientApi(ModelProvider.Claude); } else { api = new ClientApi(ModelProvider.GPT); } @@ -494,7 +501,6 @@ export const useChatStore = createPersistStore( tokenCount += estimateTokenLength(getMessageTextContent(msg)); reversedRecentMessages.push(msg); } - // concat all messages const recentMessages = [ ...systemPrompts, @@ -533,6 +539,8 @@ export const useChatStore = createPersistStore( var api: ClientApi; if (modelConfig.model.startsWith("gemini")) { api = new ClientApi(ModelProvider.GeminiPro); + } else if (modelConfig.model.startsWith("claude")) { + api = new ClientApi(ModelProvider.Claude); } else { api = new ClientApi(ModelProvider.GPT); } diff --git a/app/typing.ts b/app/typing.ts index 25e474abf1d..b09722ab902 100644 --- a/app/typing.ts +++ b/app/typing.ts @@ -1 +1,9 @@ export type Updater = (updater: (value: T) => void) => void; + +export const ROLES = ["system", "user", "assistant"] as const; +export type MessageRole = (typeof ROLES)[number]; + +export interface RequestMessage { + role: MessageRole; + content: string; +} diff --git a/app/utils.ts b/app/utils.ts index b4fc1980ce3..2745f5ca2db 100644 --- a/app/utils.ts +++ b/app/utils.ts @@ -2,16 +2,17 @@ import { useEffect, useState } from "react"; import { showToast } from "./components/ui-lib"; import Locale from "./locales"; import { RequestMessage } from "./client/api"; -import { DEFAULT_MODELS } from "./constant"; export function trimTopic(topic: string) { // Fix an issue where double quotes still show in the Indonesian language // This will remove the specified punctuation from the end of the string // and also trim quotes from both the start and end if they exist. - return topic - // fix for gemini - .replace(/^["“”*]+|["“”*]+$/g, "") - .replace(/[,。!?”“"、,.!?*]*$/, ""); + return ( + topic + // fix for gemini + .replace(/^["“”*]+|["“”*]+$/g, "") + .replace(/[,。!?”“"、,.!?*]*$/, "") + ); } export async function copyToClipboard(text: string) { @@ -57,10 +58,7 @@ export async function downloadAs(text: string, filename: string) { if (result !== null) { try { - await window.__TAURI__.fs.writeTextFile( - result, - text - ); + await window.__TAURI__.fs.writeTextFile(result, text); showToast(Locale.Download.Success); } catch (error) { showToast(Locale.Download.Failed); @@ -293,10 +291,7 @@ export function getMessageImages(message: RequestMessage): string[] { export function isVisionModel(model: string) { // Note: This is a better way using the TypeScript feature instead of `&&` or `||` (ts v5.5.0-dev.20240314 I've been using) - const visionKeywords = [ - "vision", - "claude-3", - ]; + const visionKeywords = ["vision", "claude-3"]; - return visionKeywords.some(keyword => model.includes(keyword)); + return visionKeywords.some((keyword) => model.includes(keyword)); } diff --git a/app/utils/object.ts b/app/utils/object.ts new file mode 100644 index 00000000000..b2588779de9 --- /dev/null +++ b/app/utils/object.ts @@ -0,0 +1,17 @@ +export function omit( + obj: T, + ...keys: U +): Omit { + const ret: any = { ...obj }; + keys.forEach((key) => delete ret[key]); + return ret; +} + +export function pick( + obj: T, + ...keys: U +): Pick { + const ret: any = {}; + keys.forEach((key) => (ret[key] = obj[key])); + return ret; +} diff --git a/next.config.mjs b/next.config.mjs index c8e7adb83db..daaeba46865 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -77,6 +77,10 @@ if (mode !== "export") { source: "/api/proxy/openai/:path*", destination: "https://api.openai.com/:path*", }, + { + source: "/api/proxy/anthropic/:path*", + destination: "https://api.anthropic.com/:path*", + }, { source: "/google-fonts/:path*", destination: "https://fonts.googleapis.com/:path*",