Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,9 @@ SILICONFLOW_API_KEY=

### siliconflow Api url (optional)
SILICONFLOW_URL=

### openrouter Api key (optional)
OPENROUTER_API_KEY=

### openrouter Api url (optional)
OPENROUTER_URL=
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,9 @@ To control custom models, use `+` to add a custom model, use `-` to hide a model

User `-all` to disable all default models, `+all` to enable all default models.

Models from OpenRouter (except `openrouter/auto`) need to be configured manually, use `+provider/model@OpenRouter`.
> Example: `+qwen/qwen3-32b:free@OpenRouter` will show `qwen/qwen3-32b:free(OpenRouter)` in model list.

For Azure: use `modelName@Azure=deploymentName` to customize model name and deployment name.
> Example: `+gpt-3.5-turbo@Azure=gpt35` will show option `gpt35(Azure)` in model list.
> If you only can use Azure model, `-all,+gpt-3.5-turbo@Azure=gpt35` will `gpt35(Azure)` the only option in model list.
Expand Down Expand Up @@ -361,6 +364,14 @@ SiliconFlow API Key.

SiliconFlow API URL.

### `OPENROUTER_API_KEY` (optional)

OpenRouter API Key.

### `OPENROUTER_URL` (optional)

OpenRouter API URL.

## Requirements

NodeJS >= 18, Docker >= 20
Expand Down
11 changes: 11 additions & 0 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,9 @@ DeepSeek Api Url.

用来控制模型列表,使用 `+` 增加一个模型,使用 `-` 来隐藏一个模型,使用 `模型名=展示名` 来自定义模型的展示名,用英文逗号隔开。

OpenRouter 提供的模型(除 `openrouter/auto` 外)需要手动配置,使用 `+provider/model@OpenRouter`。
> 示例:`+qwen/qwen3-32b:free@OpenRouter` 这个配置会在模型列表显示一个 `qwen/qwen3-32b:free(OpenRouter)` 的选项。

在Azure的模式下,支持使用`modelName@Azure=deploymentName`的方式配置模型名称和部署名称(deploy-name)
> 示例:`+gpt-3.5-turbo@Azure=gpt35`这个配置会在模型列表显示一个`gpt35(Azure)`的选项。
> 如果你只能使用Azure模式,那么设置 `-all,+gpt-3.5-turbo@Azure=gpt35` 则可以让对话的默认使用 `gpt35(Azure)`
Expand Down Expand Up @@ -275,6 +278,14 @@ SiliconFlow API Key.

SiliconFlow API URL.

### `OPENROUTER_API_KEY` (optional)

OpenRouter API Key.

### `OPENROUTER_URL` (optional)

OpenRouter API URL.

## 开发

点击下方按钮,开始二次开发:
Expand Down
3 changes: 3 additions & 0 deletions app/api/[provider]/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { handle as siliconflowHandler } from "../../siliconflow";
import { handle as xaiHandler } from "../../xai";
import { handle as chatglmHandler } from "../../glm";
import { handle as proxyHandler } from "../../proxy";
import { handle as openrouterHandler } from "../../openrouter";

async function handle(
req: NextRequest,
Expand Down Expand Up @@ -50,6 +51,8 @@ async function handle(
return chatglmHandler(req, { params });
case ApiPath.SiliconFlow:
return siliconflowHandler(req, { params });
case ApiPath.OpenRouter:
return openrouterHandler(req, { params });
case ApiPath.OpenAI:
return openaiHandler(req, { params });
default:
Expand Down
3 changes: 3 additions & 0 deletions app/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ export function auth(req: NextRequest, modelProvider: ModelProvider) {
case ModelProvider.SiliconFlow:
systemApiKey = serverConfig.siliconFlowApiKey;
break;
case ModelProvider.OpenRouter:
systemApiKey = serverConfig.openrouterApiKey;
break;
case ModelProvider.GPT:
default:
if (req.nextUrl.pathname.includes("azure/deployments")) {
Expand Down
128 changes: 128 additions & 0 deletions app/api/openrouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { getServerSideConfig } from "@/app/config/server";
import {
OPENROUTER_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 { isModelNotavailableInServer } from "@/app/utils/model";

const serverConfig = getServerSideConfig();

export async function handle(
req: NextRequest,
{ params }: { params: { path: string[] } },
) {
console.log("[OpenRouter Route] params ", params);

if (req.method === "OPTIONS") {
return NextResponse.json({ body: "OK" }, { status: 200 });
}

const authResult = auth(req, ModelProvider.OpenRouter);
if (authResult.error) {
return NextResponse.json(authResult, {
status: 401,
});
}

try {
const response = await request(req);
return response;
} catch (e) {
console.error("[OpenRouter] ", e);
return NextResponse.json(prettyObject(e));
}
}

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.OpenRouter, "");

let baseUrl = serverConfig.openrouterUrl || OPENROUTER_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,
};

Comment on lines +69 to +80
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Body stream consumed twice – may forward an empty body

  1. fetchOptions.body is first initialised with req.body (a ReadableStream).
  2. Later await req.text() is invoked to inspect/clone the payload, which consumes that stream.
  3. If serverConfig.customModels is falsy, the clonedBody replacement never occurs and the already-consumed stream is forwarded, resulting in an empty request body at OpenRouter.
-  const fetchOptions: RequestInit = {
-    ...
-    body: req.body,
+  const rawBody = await req.text();      // consume once
+  const fetchOptions: RequestInit = {
+    ...
+    body: rawBody.length ? rawBody : undefined,
   ...
-  if (serverConfig.customModels && req.body) {
+  if (serverConfig.customModels && rawBody.length) {
     ...
-      const clonedBody = await req.text();
-      fetchOptions.body = clonedBody;
+      const jsonBody = JSON.parse(rawBody) as { model?: string };

This guarantees the body is read a single time and forwarded intact.

Also applies to: 82-110

// #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 (
isModelNotavailableInServer(
serverConfig.customModels,
jsonBody?.model as string,
ServiceProvider.OpenRouter as string,
)
) {
return NextResponse.json(
{
error: true,
message: `you are not allowed to use ${jsonBody?.model} model`,
},
{
status: 403,
},
);
}
} catch (e) {
console.error(`[OpenRouter] 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);
}
}
12 changes: 12 additions & 0 deletions app/client/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { DeepSeekApi } from "./platforms/deepseek";
import { XAIApi } from "./platforms/xai";
import { ChatGLMApi } from "./platforms/glm";
import { SiliconflowApi } from "./platforms/siliconflow";
import { OpenRouterApi } from "./platforms/openrouter";

export const ROLES = ["system", "user", "assistant"] as const;
export type MessageRole = (typeof ROLES)[number];
Expand Down Expand Up @@ -173,6 +174,9 @@ export class ClientApi {
case ModelProvider.SiliconFlow:
this.llm = new SiliconflowApi();
break;
case ModelProvider.OpenRouter:
this.llm = new OpenRouterApi();
break;
default:
this.llm = new ChatGPTApi();
}
Expand Down Expand Up @@ -265,6 +269,8 @@ export function getHeaders(ignoreHeaders: boolean = false) {
const isChatGLM = modelConfig.providerName === ServiceProvider.ChatGLM;
const isSiliconFlow =
modelConfig.providerName === ServiceProvider.SiliconFlow;
const isOpenRouter =
modelConfig.providerName === ServiceProvider.OpenRouter;
const isEnabledAccessControl = accessStore.enabledAccessControl();
const apiKey = isGoogle
? accessStore.googleApiKey
Expand All @@ -286,6 +292,8 @@ export function getHeaders(ignoreHeaders: boolean = false) {
? accessStore.chatglmApiKey
: isSiliconFlow
? accessStore.siliconflowApiKey
: isOpenRouter
? accessStore.openrouterApiKey
: isIflytek
? accessStore.iflytekApiKey && accessStore.iflytekApiSecret
? accessStore.iflytekApiKey + ":" + accessStore.iflytekApiSecret
Expand All @@ -304,6 +312,7 @@ export function getHeaders(ignoreHeaders: boolean = false) {
isXAI,
isChatGLM,
isSiliconFlow,
isOpenRouter,
apiKey,
isEnabledAccessControl,
};
Expand Down Expand Up @@ -332,6 +341,7 @@ export function getHeaders(ignoreHeaders: boolean = false) {
isXAI,
isChatGLM,
isSiliconFlow,
isOpenRouter,
apiKey,
isEnabledAccessControl,
} = getConfig();
Expand Down Expand Up @@ -382,6 +392,8 @@ export function getClientApi(provider: ServiceProvider): ClientApi {
return new ClientApi(ModelProvider.ChatGLM);
case ServiceProvider.SiliconFlow:
return new ClientApi(ModelProvider.SiliconFlow);
case ServiceProvider.OpenRouter:
return new ClientApi(ModelProvider.OpenRouter);
default:
return new ClientApi(ModelProvider.GPT);
}
Expand Down
Loading