Compare commits

..
Author SHA1 Message Date
Dayuan Jiang 18a20aa1ca feat(api): count the quota by the CDN's client IP and refuse direct calls (#956)
Two optional settings for deployments behind a CDN such as Cloudflare.

CLIENT_IP_HEADER names the header that holds the visitor's real IP
(cf-connecting-ip on Cloudflare). The per-IP daily quota used the first
X-Forwarded-For entry, which visitors can set to anything, so a made-up
IP on every request got a fresh quota.

ORIGIN_SECRET makes proxy.ts refuse /api requests whose X-Origin-Secret
header does not match. The CDN adds the header, so a call that skips the
CDN, and could fake the IP header, gets 403. Pages are not checked, which
keeps health checks on / working.

Both are unset by default, and nothing changes then.
2026-10-06 10:32:43 +09:00
dependabot[bot] 802f0ada9f fix(deps): update vulnerable dependencies with npm audit fix (#804)
Root: 36 vulnerable packages to 9, no critical left (next 16.3.8, electron 39.8.10). MCP server: 11 to 0.
2026-10-06 09:56:06 +09:00
Marvelous Ikponmwosa 257a4bca06 docs: explain how to use LM Studio with the OpenAI provider (#897)
Set the base URL to http://localhost:1234/v1 and use any non-empty API key.
2026-10-06 09:55:26 +09:00
Dayuan Jiang 83f190e1cd feat(chat): show a quota hint when the server's key is refused (#953)
On the server's own credentials a provider 403 now gets its own code,
server_key_forbidden, with a hint in all four languages: today's free
quota is used up, it resets tomorrow, and users can add their own key
in model settings. The generic "The provider returned an error." line
is left out for this code.

A daily spend cap that blocks a shared key makes every call return 403.
The old hint said the key may lack access to the model or region, which
points users at a setting they cannot change.

A 403 on the user's own key keeps the old hint and the provider's text.
2026-10-06 09:49:48 +09:00
Dayuan Jiang 8639779a23 fix(ollama): send chat requests to the /api path (#954)
The Ollama SDK appends /chat to the base URL, and Ollama serves chat at
/api/chat. The model list already added the missing /api, so with
"http://localhost:11434" (the address our docs showed) models were listed
but every chat request went to /chat and got a 404. An OpenAI-style
".../v1" address went to /v1/chat.

ollamaApiUrl() turns the server address, ".../v1" and ".../api" into
".../api". Chat, the model list and the settings dialog's request URL
hint all use it. The docs now show http://localhost:11434/api, which also
works on released versions.
2026-10-06 09:37:05 +09:00
14 changed files with 1092 additions and 1276 deletions
+3 -1
View File
@@ -46,6 +46,8 @@ AI_MODEL=gpt-4o
OPENAI_BASE_URL=https://your-custom-endpoint/v1
```
LM Studio 等本地 OpenAI 兼容服务也用同样的方式:把 base URL 设为 `http://localhost:1234/v1`(LM Studio 的默认端口),API 密钥填任意非空值即可,例如 `lm-studio`。在上面的环境变量或模型设置里配置都可以。
### AIHubMix
AIHubMix 通过单个 API Key 聚合 Claude、GPT、Gemini、DeepSeek 等模型。
@@ -192,7 +194,7 @@ MODELSCOPE_BASE_URL=https://your-custom-endpoint
可选的自定义 URL:
```bash
OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_BASE_URL=http://localhost:11434/api
```
### Vercel AI Gateway
+3 -1
View File
@@ -61,6 +61,8 @@ Optional custom endpoint (for OpenAI-compatible services):
OPENAI_BASE_URL=https://your-custom-endpoint/v1
```
LM Studio and other local OpenAI-compatible servers work the same way: set the base URL to `http://localhost:1234/v1` (LM Studio's default port) and use any non-empty API key, such as `lm-studio`. This works both in the environment variables above and in the model settings.
### AIHubMix
AIHubMix provides access to Claude, GPT, Gemini, DeepSeek, and other models through a single API key.
@@ -194,7 +196,7 @@ AI_MODEL=llama3.2
Optional custom URL:
```bash
OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_BASE_URL=http://localhost:11434/api
```
### ModelScope
+3 -1
View File
@@ -46,6 +46,8 @@ AI_MODEL=gpt-4o
OPENAI_BASE_URL=https://your-custom-endpoint/v1
```
LM Studio などのローカルな OpenAI 互換サーバーも同じ方法で使えます。ベース URL を `http://localhost:1234/v1`(LM Studio の既定ポート)に設定し、API キーには `lm-studio` など空でない任意の値を入力してください。上の環境変数でも、モデル設定画面でも設定できます。
### AIHubMix
AIHubMix は、単一の API キーで Claude、GPT、Gemini、DeepSeek などのモデルへのアクセスを提供します。
@@ -179,7 +181,7 @@ AI_MODEL=llama3.2
任意のカスタム URL:
```bash
OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_BASE_URL=http://localhost:11434/api
```
### ModelScope
+11 -1
View File
@@ -70,7 +70,7 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# AZURE_REASONING_SUMMARY=detailed
# Ollama Configuration (Local or Cloud)
# OLLAMA_BASE_URL=https://ollama.com/api # Optional: Ollama Cloud; defaults to local Ollama (http://127.0.0.1:11434)
# OLLAMA_BASE_URL=https://ollama.com/api # Optional: Ollama Cloud; defaults to local Ollama (http://127.0.0.1:11434/api)
# OLLAMA_API_KEY=your-ollama-cloud-api-key # Optional: For Ollama Cloud or authenticated remote instances
# OLLAMA_ENABLE_THINKING=true # Optional: Enable thinking for models that support it (e.g., qwen3)
@@ -163,6 +163,16 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# Set to "false" to block private IPs, localhost, and internal hostnames
# ALLOW_PRIVATE_URLS=false
# Behind a CDN such as Cloudflare (Optional)
# The daily quota is counted per IP. By default the IP is the first
# X-Forwarded-For entry, which visitors can set to anything. Name the header
# your CDN fills with the visitor's real IP instead:
# CLIENT_IP_HEADER=cf-connecting-ip
# Then have the CDN add an X-Origin-Secret header with this value to every
# request. API calls without it get 403, so nobody can skip the CDN and fake
# the IP header above.
# ORIGIN_SECRET=a-long-random-string
# Self-hosted deployment (Optional)
# Self-hosted users may implement custom quota-management solutions,
# which triggers the client UI to display messages suggesting self-hosting or sponsorship.
+2 -1
View File
@@ -25,6 +25,7 @@ import { getEnvFallback } from "@/lib/admin/settings"
import { isPrivateUrl, redirectGuardedFetch } from "@/lib/ssrf-protection"
import {
normalizeBaseUrl,
ollamaApiUrl,
PROVIDER_INFO,
type ProviderName,
} from "@/lib/types/model-config"
@@ -1025,7 +1026,7 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig {
? PROVIDER_INFO.ollama.defaultBaseUrl
: resolveBaseUrlEnv(overrides, "OLLAMA_BASE_URL"))
model = createOllama({
...(baseURL && { baseURL }),
...(baseURL && { baseURL: ollamaApiUrl(baseURL) }),
...(apiKey && {
headers: { Authorization: `Bearer ${apiKey}` },
}),
+6 -2
View File
@@ -3,6 +3,7 @@ import { getModelInfo } from "@/lib/model-catalog"
import { readLimitedBody } from "@/lib/read-limited-body"
import {
normalizeBaseUrl,
ollamaApiUrl,
PROVIDER_INFO,
type ProviderName,
} from "@/lib/types/model-config"
@@ -200,8 +201,11 @@ export async function listProviderModels(
break
}
case "ollama": {
const api = base.endsWith("/api") ? base : `${base}/api`
const data = await getJson(`${api}/tags`, bearer, fetchFn)
const data = await getJson(
`${ollamaApiUrl(base)}/tags`,
bearer,
fetchFn,
)
models = (data.models ?? []).map((m: { name: string }) => ({
id: m.name,
}))
+10 -1
View File
@@ -622,6 +622,15 @@ export function normalizeBaseUrl(url: string): string {
.replace(/\/(?:chat\/completions|completions|messages|responses)$/, "")
}
/**
* Ollama's native API root, which the SDK appends /chat to and the model
* list /tags. Users often enter the server address ("http://localhost:11434")
* or its OpenAI-compatible one (".../v1"); both get /api.
*/
export function ollamaApiUrl(baseUrl: string): string {
return `${normalizeBaseUrl(baseUrl).replace(/\/(?:api|v1)$/, "")}/api`
}
/** Where a chat request goes for a base URL, or null when the SDK decides */
export function chatRequestUrl(
provider: ProviderName,
@@ -630,6 +639,7 @@ export function chatRequestUrl(
const url = normalizeBaseUrl(baseUrl)
if (!url) return null
if (provider === "anthropic") return `${url}/messages`
if (provider === "ollama") return `${ollamaApiUrl(url)}/chat`
// These SDKs build their own paths (or, for MiniMax, pick the protocol
// from the URL)
const ownPaths: ProviderName[] = [
@@ -637,7 +647,6 @@ export function chatRequestUrl(
"vertexai",
"azure",
"bedrock",
"ollama",
"gateway",
"minimax",
"edgeone",
+11 -2
View File
@@ -2,10 +2,19 @@
* Generate a userId from request for tracking purposes.
* Uses base64url encoding of IP for URL-safe identifier.
* Note: base64 is reversible - this is NOT privacy protection.
*
* The first X-Forwarded-For entry is whatever the visitor sent, so behind a
* CDN set CLIENT_IP_HEADER to the header it fills with the real IP (e.g.
* cf-connecting-ip), and ORIGIN_SECRET so requests that skip the CDN are
* refused (see proxy.ts).
*/
export function getUserIdFromRequest(req: Request): string {
const forwardedFor = req.headers.get("x-forwarded-for")
const rawIp = forwardedFor?.split(",")[0]?.trim() || "anonymous"
const ipHeader = process.env.CLIENT_IP_HEADER
const rawIp =
(ipHeader
? req.headers.get(ipHeader)?.trim()
: req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()) ||
"anonymous"
return rawIp === "anonymous"
? rawIp
: `user-${Buffer.from(rawIp).toString("base64url")}`
+815 -1188
View File
File diff suppressed because it is too large Load Diff
+102 -70
View File
@@ -507,12 +507,12 @@
}
},
"node_modules/@hono/node-server": {
"version": "1.19.9",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
"integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==",
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.3.tgz",
"integrity": "sha512-TA//nWMqPhbfdfneACk6t5a9eqbS9lABEPyKn0/xZTah3H3U2XaVg85rJFl0/Fyit0I552YDHgXGVSf3GwqbUw==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
"node": ">=20"
},
"peerDependencies": {
"hono": "^4"
@@ -1045,9 +1045,9 @@
}
},
"node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -1088,20 +1088,20 @@
}
},
"node_modules/body-parser": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz",
"integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"content-type": "^2.0.0",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"http-errors": "^2.0.1",
"iconv-lite": "^0.7.2",
"on-finished": "^2.4.1",
"qs": "^6.14.0",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
"qs": "^6.15.2",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
},
"engines": {
"node": ">=18"
@@ -1111,6 +1111,19 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/boolbase": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",
@@ -1577,9 +1590,9 @@
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -1730,12 +1743,13 @@
}
},
"node_modules/express-rate-limit": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz",
"integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==",
"license": "MIT",
"dependencies": {
"ip-address": "10.0.1"
"debug": "^4.4.3",
"ip-address": "^10.2.0"
},
"engines": {
"node": ">= 16"
@@ -1754,9 +1768,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"funding": [
{
"type": "github",
@@ -1912,9 +1926,9 @@
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -1924,9 +1938,9 @@
}
},
"node_modules/hono": {
"version": "4.11.9",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz",
"integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
"version": "4.13.13",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz",
"integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -1990,9 +2004,9 @@
}
},
"node_modules/iconv-lite": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz",
"integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==",
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -2012,9 +2026,9 @@
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"version": "10.7.3",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz",
"integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -2479,9 +2493,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"version": "3.3.20",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
"dev": true,
"funding": [
{
@@ -2645,9 +2659,9 @@
}
},
"node_modules/path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -2691,9 +2705,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"version": "8.5.29",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
"integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
"dev": true,
"funding": [
{
@@ -2711,9 +2725,9 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.19",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
"source-map-js": "^1.2.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
@@ -2745,12 +2759,13 @@
}
},
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
@@ -2945,14 +2960,14 @@
}
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
@@ -2964,13 +2979,13 @@
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
@@ -3024,9 +3039,9 @@
"license": "ISC"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
@@ -3137,17 +3152,34 @@
}
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"content-type": "^2.0.0",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/type-is/node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/typescript": {
+17 -4
View File
@@ -27,9 +27,19 @@ function getLocale(request: NextRequest): string | undefined {
export function proxy(request: NextRequest) {
const pathname = request.nextUrl.pathname
// Skip API routes, static files, and Next.js internals
if (pathname.startsWith("/api/")) {
// With ORIGIN_SECRET set, API calls must come through the CDN that
// adds this header. A call straight to the origin could fake the
// CLIENT_IP_HEADER and get a fresh quota for every made-up IP.
const secret = process.env.ORIGIN_SECRET
if (secret && request.headers.get("x-origin-secret") !== secret) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
return
}
// Skip static files and Next.js internals
if (
pathname.startsWith("/api/") ||
pathname.startsWith("/_next/") ||
pathname.startsWith("/drawio") ||
pathname.includes("/favicon") ||
@@ -58,6 +68,9 @@ export function proxy(request: NextRequest) {
}
export const config = {
// Matcher ignoring `/_next/` and `/api/`
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
// API routes (for ORIGIN_SECRET), and pages without `/_next/` assets
matcher: [
"/api/:path*",
"/((?!api|_next/static|_next/image|favicon.ico).*)",
],
}
+20 -3
View File
@@ -434,7 +434,7 @@ describe("Ollama API key security", () => {
expect(createOllamaMock).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: "https://my-ollama.com",
baseURL: "https://my-ollama.com/api",
headers: { Authorization: "Bearer client-key" },
}),
)
@@ -448,7 +448,7 @@ describe("Ollama API key security", () => {
expect(createOllamaMock).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: "https://cloud.ollama.com",
baseURL: "https://cloud.ollama.com/api",
headers: { Authorization: "Bearer server-key" },
}),
)
@@ -483,7 +483,24 @@ describe("Ollama API key security", () => {
expect(createOllamaMock).toHaveBeenCalledTimes(1)
const callArgs = createOllamaMock.mock.calls[0][0]
expect(callArgs.baseURL).toBe("https://my-ollama.com")
expect(callArgs.baseURL).toBe("https://my-ollama.com/api")
expect(callArgs).not.toHaveProperty("headers")
})
it("sends chat to Ollama's /api for a server address or a /v1 URL", () => {
delete process.env.OLLAMA_API_KEY
for (const baseUrl of [
"http://localhost:11434",
"http://localhost:11434/",
"http://localhost:11434/v1",
"http://localhost:11434/api",
]) {
createOllamaMock.mockClear()
getAIModel({ provider: "ollama", baseUrl, modelId: "llama3.2" })
expect(createOllamaMock.mock.calls[0][0].baseURL).toBe(
"http://localhost:11434/api",
)
}
})
})
+26 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest"
import { chatRequestUrl, normalizeBaseUrl } from "@/lib/types/model-config"
import {
chatRequestUrl,
normalizeBaseUrl,
ollamaApiUrl,
} from "@/lib/types/model-config"
describe("normalizeBaseUrl", () => {
it("drops spaces, trailing slashes and a pasted endpoint path", () => {
@@ -32,6 +36,9 @@ describe("chatRequestUrl", () => {
expect(
chatRequestUrl("anthropic", "https://proxy.example.com/v1"),
).toBe("https://proxy.example.com/v1/messages")
expect(chatRequestUrl("ollama", "http://localhost:11434")).toBe(
"http://localhost:11434/api/chat",
)
})
it("stays out of the way for SDKs that build their own paths", () => {
@@ -40,3 +47,21 @@ describe("chatRequestUrl", () => {
expect(chatRequestUrl("glm", " ")).toBeNull()
})
})
describe("ollamaApiUrl", () => {
it("points at Ollama's /api whatever form the address takes", () => {
for (const url of [
"http://localhost:11434",
"http://localhost:11434/",
"http://localhost:11434/api",
"http://localhost:11434/api/",
"http://localhost:11434/v1",
"http://localhost:11434/v1/chat/completions",
]) {
expect(ollamaApiUrl(url)).toBe("http://localhost:11434/api")
}
expect(ollamaApiUrl("https://ollama.com/api")).toBe(
"https://ollama.com/api",
)
})
})
+63
View File
@@ -0,0 +1,63 @@
import { NextRequest } from "next/server"
import { afterEach, describe, expect, it } from "vitest"
import { getUserIdFromRequest } from "@/lib/user-id"
import { proxy } from "@/proxy"
const idFor = (ip: string) => `user-${Buffer.from(ip).toString("base64url")}`
afterEach(() => {
delete process.env.CLIENT_IP_HEADER
delete process.env.ORIGIN_SECRET
})
describe("getUserIdFromRequest", () => {
const req = new Request("http://localhost/api/chat", {
headers: {
"x-forwarded-for": "203.0.113.9, 198.51.100.7",
"cf-connecting-ip": "198.51.100.7",
},
})
it("uses the first X-Forwarded-For entry by default", () => {
expect(getUserIdFromRequest(req)).toBe(idFor("203.0.113.9"))
})
it("uses CLIENT_IP_HEADER when set", () => {
process.env.CLIENT_IP_HEADER = "cf-connecting-ip"
expect(getUserIdFromRequest(req)).toBe(idFor("198.51.100.7"))
})
it("is anonymous when the configured header is missing", () => {
process.env.CLIENT_IP_HEADER = "cf-connecting-ip"
expect(
getUserIdFromRequest(new Request("http://localhost/api/chat")),
).toBe("anonymous")
})
})
describe("ORIGIN_SECRET", () => {
const call = (path: string, secret?: string) =>
proxy(
new NextRequest(`http://localhost${path}`, {
headers: secret ? { "x-origin-secret": secret } : {},
}),
)
it("lets every API call through when unset", () => {
expect(call("/api/chat")).toBeUndefined()
})
it("refuses API calls without the right header", async () => {
process.env.ORIGIN_SECRET = "s3cret"
for (const res of [call("/api/chat"), call("/api/chat", "wrong")]) {
expect(res?.status).toBe(403)
}
expect(call("/api/chat", "s3cret")).toBeUndefined()
})
it("leaves pages alone, such as the health check on /", () => {
process.env.ORIGIN_SECRET = "s3cret"
expect(call("/")?.status).toBe(307)
expect(call("/en")).toBeUndefined()
})
})