diff --git a/app/api/[provider]/[...path]/route.ts b/app/api/[provider]/[...path]/route.ts index 06e3e5160..24aa5ec04 100644 --- a/app/api/[provider]/[...path]/route.ts +++ b/app/api/[provider]/[...path]/route.ts @@ -10,6 +10,8 @@ import { handle as alibabaHandler } from "../../alibaba"; import { handle as moonshotHandler } from "../../moonshot"; import { handle as stabilityHandler } from "../../stability"; import { handle as iflytekHandler } from "../../iflytek"; +import { handle as proxyHandler } from "../../proxy"; + async function handle( req: NextRequest, { params }: { params: { provider: string; path: string[] } }, @@ -36,8 +38,10 @@ async function handle( return stabilityHandler(req, { params }); case ApiPath.Iflytek: return iflytekHandler(req, { params }); - default: + case ApiPath.OpenAI: return openaiHandler(req, { params }); + default: + return proxyHandler(req, { params }); } } diff --git a/app/api/proxy.ts b/app/api/proxy.ts new file mode 100644 index 000000000..731003aa1 --- /dev/null +++ b/app/api/proxy.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function handle( + req: NextRequest, + { params }: { params: { path: string[] } }, +) { + console.log("[Proxy Route] params ", params); + + if (req.method === "OPTIONS") { + return NextResponse.json({ body: "OK" }, { status: 200 }); + } + + // remove path params from searchParams + req.nextUrl.searchParams.delete("path"); + req.nextUrl.searchParams.delete("provider"); + + const subpath = params.path.join("/"); + const fetchUrl = `${req.headers.get( + "x-base-url", + )}/${subpath}?${req.nextUrl.searchParams.toString()}`; + const skipHeaders = ["connection", "host", "origin", "referer", "cookie"]; + const headers = new Headers( + Array.from(req.headers.entries()).filter((item) => { + if ( + item[0].indexOf("x-") > -1 || + item[0].indexOf("sec-") > -1 || + skipHeaders.includes(item[0]) + ) { + return false; + } + return true; + }), + ); + const controller = new AbortController(); + const fetchOptions: RequestInit = { + headers, + method: req.method, + body: req.body, + // to fix #2485: https://stackoverflow.com/questions/55920957/cloudflare-worker-typeerror-one-time-use-body + redirect: "manual", + // @ts-ignore + duplex: "half", + signal: controller.signal, + }; + + const timeoutId = setTimeout( + () => { + controller.abort(); + }, + 10 * 60 * 1000, + ); + + 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"); + + // The latest version of the OpenAI API forced the content-encoding to be "br" in json response + // So if the streaming is disabled, we need to remove the content-encoding header + // Because Vercel uses gzip to compress the response, if we don't remove the content-encoding header + // The browser will try to decode the response with brotli and fail + newHeaders.delete("content-encoding"); + + return new Response(res.body, { + status: res.status, + statusText: res.statusText, + headers: newHeaders, + }); + } finally { + clearTimeout(timeoutId); + } +} diff --git a/app/components/plugin.tsx b/app/components/plugin.tsx index 35cda9089..5aa66ce94 100644 --- a/app/components/plugin.tsx +++ b/app/components/plugin.tsx @@ -14,10 +14,12 @@ import CloseIcon from "../icons/close.svg"; import DeleteIcon from "../icons/delete.svg"; import EyeIcon from "../icons/eye.svg"; import CopyIcon from "../icons/copy.svg"; +import ConfirmIcon from "../icons/confirm.svg"; import { Plugin, usePluginStore, FunctionToolService } from "../store/plugin"; import { Input, + PasswordInput, List, ListItem, Modal, @@ -191,55 +193,102 @@ export function PluginPage() { onClose={closePluginModal} actions={[ } - text={Locale.Plugin.EditModal.Download} + icon={} + text={Locale.UI.Confirm} key="export" bordered - onClick={() => - downloadAs( - JSON.stringify(editingPlugin), - `${editingPlugin.title}@${editingPlugin.version}.json`, - ) - } + onClick={() => setEditingPluginId("")} />, ]} > -
-
- {Locale.Plugin.EditModal.Content} -
-
+ + + + {editingPlugin.authType == "custom" && ( + + { + pluginStore.updatePlugin(editingPlugin.id, (plugin) => { + plugin.authHeader = e.target.value; + }); + }} + > + + )} + {["bearer", "basic", "custom"].includes( + editingPlugin.authType as string, + ) && ( + + { + pluginStore.updatePlugin(editingPlugin.id, (plugin) => { + plugin.authToken = e.currentTarget.value; + }); + }} + > + + )} + -
-                  
-                
-
-
- {Locale.Plugin.EditModal.Method} -
-
- {editingPluginTool?.tools.map((tool, index) => ( -
-
-
-
- {tool?.function?.name} -
-
- {tool?.function?.description} -
-
-
+ { + pluginStore.updatePlugin(editingPlugin.id, (plugin) => { + plugin.usingProxy = e.currentTarget.checked; + }); + }} + > + + + + +
+                      
+                    
- ))} -
-
+ } + > + {editingPluginTool?.tools.map((tool, index) => ( + + ))} + )} diff --git a/app/components/ui-lib.tsx b/app/components/ui-lib.tsx index fd78f9c47..828c9a27d 100644 --- a/app/components/ui-lib.tsx +++ b/app/components/ui-lib.tsx @@ -51,7 +51,7 @@ export function Card(props: { children: JSX.Element[]; className?: string }) { export function ListItem(props: { title: string; - subTitle?: string; + subTitle?: string | JSX.Element; children?: JSX.Element | JSX.Element[]; icon?: JSX.Element; className?: string; diff --git a/app/locales/cn.ts b/app/locales/cn.ts index f0ff705c1..af600564e 100644 --- a/app/locales/cn.ts +++ b/app/locales/cn.ts @@ -546,9 +546,20 @@ const cn = { Delete: "删除", DeleteConfirm: "确认删除?", }, + Auth: { + None: "不需要授权", + Basic: "Basic", + Bearer: "Bearer", + Custom: "自定义", + CustomHeader: "自定义头", + Token: "Token", + Proxy: "使用代理", + ProxyDescription: "使用代理解决 CORS 错误", + }, EditModal: { Title: (readonly: boolean) => `编辑插件 ${readonly ? "(只读)" : ""}`, Download: "下载", + Auth: "授权方式", Content: "OpenAPI Schema", Method: "方法", Error: "格式错误", diff --git a/app/locales/en.ts b/app/locales/en.ts index 15db8190a..59a284006 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -554,10 +554,21 @@ const en: LocaleType = { Delete: "Delete", DeleteConfirm: "Confirm to delete?", }, + Auth: { + None: "None", + Basic: "Basic", + Bearer: "Bearer", + Custom: "Custom", + CustomHeader: "Custom Header", + Token: "Token", + Proxy: "Using Proxy", + ProxyDescription: "Using proxies to solve CORS error", + }, EditModal: { Title: (readonly: boolean) => `Edit Plugin ${readonly ? "(readonly)" : ""}`, Download: "Download", + Auth: "Authentication Type", Content: "OpenAPI Schema", Method: "Method", Error: "OpenAPI Schema Error", diff --git a/app/store/plugin.ts b/app/store/plugin.ts index 031a2aaf5..d7b0de255 100644 --- a/app/store/plugin.ts +++ b/app/store/plugin.ts @@ -12,6 +12,10 @@ export type Plugin = { version: string; content: string; builtin: boolean; + authType?: string; + authHeader?: string; + authToken?: string; + usingProxy?: boolean; }; export type FunctionToolItem = { @@ -34,10 +38,30 @@ export const FunctionToolService = { tools: {} as Record, add(plugin: Plugin, replace = false) { if (!replace && this.tools[plugin.id]) return this.tools[plugin.id]; + const headerName = ( + plugin?.authType == "custom" ? plugin?.authHeader : "Authorization" + ) as string; + const tokenValue = + plugin?.authType == "basic" + ? `Basic ${plugin?.authToken}` + : plugin?.authType == "bearer" + ? ` Bearer ${plugin?.authToken}` + : plugin?.authToken; + const definition = yaml.load(plugin.content) as any; + const serverURL = definition.servers?.[0]?.url; + const baseURL = !!plugin?.usingProxy ? "/api/proxy" : serverURL; const api = new OpenAPIClientAxios({ definition: yaml.load(plugin.content) as any, + axiosConfigDefaults: { + baseURL, + headers: { + // 'Cache-Control': 'no-cache', + // 'Content-Type': 'application/json', // TODO + [headerName]: tokenValue, + "X-Base-URL": !!plugin?.usingProxy ? serverURL : undefined, + }, + }, }); - console.log("add", plugin, api); try { api.initSync(); } catch (e) {} @@ -79,14 +103,29 @@ export const FunctionToolService = { type: "function", function: { name: o.operationId, - description: o.description, + description: o.description || o.summary, parameters: parameters, }, } as FunctionToolItem; }), funcs: operations.reduce((s, o) => { // @ts-ignore - s[o.operationId] = api.client[o.operationId]; + s[o.operationId] = function (args) { + const argument = []; + if (o.parameters instanceof Array) { + o.parameters.forEach((p) => { + // @ts-ignore + argument.push(args[p?.name]); + // @ts-ignore + delete args[p?.name]; + }); + } else { + argument.push(null); + } + argument.push(args); + // @ts-ignore + return api.client[o.operationId].apply(null, argument); + }; return s; }, {}), }); @@ -136,6 +175,7 @@ export const usePluginStore = createPersistStore( const updatePlugin = { ...plugin }; updater(updatePlugin); plugins[id] = updatePlugin; + FunctionToolService.add(updatePlugin, true); set(() => ({ plugins })); get().markUpdate(); }, diff --git a/next.config.mjs b/next.config.mjs index 6b1dd0c35..27c60dd29 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -86,10 +86,6 @@ if (mode !== "export") { source: "/api/proxy/anthropic/:path*", destination: "https://api.anthropic.com/:path*", }, - { - source: "/api/proxy/gapier/:path*", - destination: "https://a.gapier.com/:path*", - }, { source: "/google-fonts/:path*", destination: "https://fonts.googleapis.com/:path*",