mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-06 17:57:47 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87af58e7db |
@@ -77,7 +77,6 @@ export default function ExamplePanel({
|
||||
minimal?: boolean
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === "true"
|
||||
|
||||
const handleReplicateFlowchart = async () => {
|
||||
setInput("Replicate this flowchart.")
|
||||
@@ -126,31 +125,29 @@ export default function ExamplePanel({
|
||||
<div className={minimal ? "" : "py-6 px-2 animate-fade-in"}>
|
||||
{!minimal && (
|
||||
<>
|
||||
{/* MCP Server Notice, hidden on self-hosted deployments */}
|
||||
{!isSelfHosted && (
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block mb-4 p-3 rounded-xl bg-gradient-to-r from-purple-500/10 to-blue-500/10 border border-purple-500/20 hover:border-purple-500/40 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
|
||||
<Terminal className="w-4 h-4 text-purple-500" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
|
||||
{dict.examples.mcpServer}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.examples.mcpDescription}
|
||||
</p>
|
||||
</div>
|
||||
{/* MCP Server Notice */}
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block mb-4 p-3 rounded-xl bg-gradient-to-r from-purple-500/10 to-blue-500/10 border border-purple-500/20 hover:border-purple-500/40 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
|
||||
<Terminal className="w-4 h-4 text-purple-500" />
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
|
||||
{dict.examples.mcpServer}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.examples.mcpDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{/* Welcome section */}
|
||||
<div className="text-center mb-6">
|
||||
|
||||
@@ -427,16 +427,13 @@ export default function ChatPanel({
|
||||
let openModelConfig = false
|
||||
if (data?.type === "provider") {
|
||||
const hints = dict.errors.llm as Record<string, string>
|
||||
const hint = hints[data.code]
|
||||
text =
|
||||
hint && data.message
|
||||
? `${hint}\n\n${data.message}`
|
||||
: hint || data.message
|
||||
text = hints[data.code]
|
||||
? `${hints[data.code]}\n\n${data.message}`
|
||||
: data.message
|
||||
openModelConfig = [
|
||||
"invalid_api_key",
|
||||
"forbidden",
|
||||
"model_not_found",
|
||||
"server_key_forbidden",
|
||||
].includes(data.code)
|
||||
} else if (typeof data?.error === "string") {
|
||||
text = data.error
|
||||
|
||||
@@ -46,8 +46,6 @@ 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 等模型。
|
||||
@@ -194,7 +192,7 @@ MODELSCOPE_BASE_URL=https://your-custom-endpoint
|
||||
可选的自定义 URL:
|
||||
|
||||
```bash
|
||||
OLLAMA_BASE_URL=http://localhost:11434/api
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
```
|
||||
|
||||
### Vercel AI Gateway
|
||||
|
||||
@@ -61,8 +61,6 @@ 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.
|
||||
@@ -196,7 +194,7 @@ AI_MODEL=llama3.2
|
||||
Optional custom URL:
|
||||
|
||||
```bash
|
||||
OLLAMA_BASE_URL=http://localhost:11434/api
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
```
|
||||
|
||||
### ModelScope
|
||||
|
||||
@@ -46,8 +46,6 @@ 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 などのモデルへのアクセスを提供します。
|
||||
@@ -181,7 +179,7 @@ AI_MODEL=llama3.2
|
||||
任意のカスタム URL:
|
||||
|
||||
```bash
|
||||
OLLAMA_BASE_URL=http://localhost:11434/api
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
```
|
||||
|
||||
### ModelScope
|
||||
|
||||
+2
-13
@@ -1,6 +1,6 @@
|
||||
# AI Provider Configuration
|
||||
# AI_PROVIDER: Which provider to use
|
||||
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax, novita, mimo, atlascloud
|
||||
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, gateway, novita
|
||||
# Default: bedrock
|
||||
AI_PROVIDER=bedrock
|
||||
|
||||
@@ -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/api)
|
||||
# OLLAMA_BASE_URL=https://ollama.com/api # Optional: Ollama Cloud; defaults to local Ollama (http://127.0.0.1:11434)
|
||||
# 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,22 +163,11 @@ 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.
|
||||
# This switch allows self-hosted users to provide custom messages in response to a 429 code,
|
||||
# in messageTokenSelfHosted, messageApiSelfHosted, and tipSelfHosted translation strings.
|
||||
# It also turns off the MCP server advertisement in the chat examples.
|
||||
# NEXT_PUBLIC_SELFHOSTED=true
|
||||
|
||||
# Minimax Configuration (Optional)
|
||||
|
||||
@@ -125,12 +125,7 @@ let writableCache: boolean | null = null
|
||||
export function isSettingsWritable(): boolean {
|
||||
if (writableCache !== null) return writableCache
|
||||
try {
|
||||
const filePath = getSettingsPath()
|
||||
if (fs.existsSync(filePath) && !fs.statSync(filePath).isFile()) {
|
||||
writableCache = false
|
||||
return writableCache
|
||||
}
|
||||
const dir = path.dirname(filePath)
|
||||
const dir = path.dirname(getSettingsPath())
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
fs.accessSync(dir, fs.constants.W_OK)
|
||||
writableCache = true
|
||||
|
||||
+1
-2
@@ -25,7 +25,6 @@ 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"
|
||||
@@ -1026,7 +1025,7 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig {
|
||||
? PROVIDER_INFO.ollama.defaultBaseUrl
|
||||
: resolveBaseUrlEnv(overrides, "OLLAMA_BASE_URL"))
|
||||
model = createOllama({
|
||||
...(baseURL && { baseURL: ollamaApiUrl(baseURL) }),
|
||||
...(baseURL && { baseURL }),
|
||||
...(apiKey && {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
}),
|
||||
|
||||
@@ -192,7 +192,6 @@
|
||||
"llm": {
|
||||
"invalid_api_key": "The provider rejected the API key. Check it in model settings.",
|
||||
"forbidden": "The provider refused the request. The key may not have access to this model or region.",
|
||||
"server_key_forbidden": "Today's free quota is used up. It resets tomorrow. You can also add your own API key in model settings to keep going.",
|
||||
"model_not_found": "The provider does not know this model. Check the model ID in model settings.",
|
||||
"insufficient_quota": "The provider account has no credit or quota left.",
|
||||
"rate_limited": "The provider is limiting requests. Wait a moment and try again.",
|
||||
|
||||
@@ -192,7 +192,6 @@
|
||||
"llm": {
|
||||
"invalid_api_key": "プロバイダーが API キーを拒否しました。モデル設定で確認してください。",
|
||||
"forbidden": "プロバイダーがリクエストを拒否しました。このキーにはこのモデルまたはリージョンの利用権限がない可能性があります。",
|
||||
"server_key_forbidden": "本日の無料枠を使い切りました。明日になると自動的に回復します。モデル設定でご自身の API キーを入力すると、引き続きご利用いただけます。",
|
||||
"model_not_found": "プロバイダーがこのモデルを認識できません。モデル設定でモデル ID を確認してください。",
|
||||
"insufficient_quota": "プロバイダーのアカウントの残高または利用枠がなくなりました。",
|
||||
"rate_limited": "プロバイダーがリクエスト数を制限しています。少し待ってから再試行してください。",
|
||||
|
||||
@@ -192,7 +192,6 @@
|
||||
"llm": {
|
||||
"invalid_api_key": "服務商拒絕了這個 API Key,請在模型設定中檢查。",
|
||||
"forbidden": "服務商拒絕了這次請求。這個 Key 可能沒有使用該模型或該地區的權限。",
|
||||
"server_key_forbidden": "今天的免費額度已經用完,明天會自動恢復。您也可以在模型設定中填寫自己的 API Key 繼續使用。",
|
||||
"model_not_found": "服務商找不到這個模型,請在模型設定中檢查模型 ID。",
|
||||
"insufficient_quota": "服務商帳戶的餘額或額度已經用完。",
|
||||
"rate_limited": "服務商正在限制請求頻率,請稍候再試。",
|
||||
|
||||
@@ -192,7 +192,6 @@
|
||||
"llm": {
|
||||
"invalid_api_key": "服务商拒绝了这个 API Key,请在模型设置里检查。",
|
||||
"forbidden": "服务商拒绝了这次请求。这个 Key 可能没有使用该模型或该地区的权限。",
|
||||
"server_key_forbidden": "今天的免费额度已经用完,明天会自动恢复。您也可以在模型设置里填写自己的 API Key 继续使用。",
|
||||
"model_not_found": "服务商找不到这个模型,请在模型设置里检查模型 ID。",
|
||||
"insufficient_quota": "服务商账户的余额或额度已经用完。",
|
||||
"rate_limited": "服务商正在限制请求频率,请稍等片刻再试。",
|
||||
|
||||
+1
-9
@@ -24,8 +24,6 @@ export type LLMErrorCode =
|
||||
| "provider_unavailable"
|
||||
| "cannot_connect"
|
||||
| "timeout"
|
||||
// A 403 on the server's own key, e.g. a daily spend cap blocked it
|
||||
| "server_key_forbidden"
|
||||
| "unknown"
|
||||
|
||||
export interface LLMError {
|
||||
@@ -132,13 +130,7 @@ export function streamErrorText(error: unknown, hideDetails = false): string {
|
||||
const classified = classifyLLMError(error)
|
||||
if (hideDetails) {
|
||||
console.error("[chat] Provider error:", error)
|
||||
if (classified.code === "forbidden") {
|
||||
// The hint says all the user can do; there is no message to add
|
||||
classified.code = "server_key_forbidden"
|
||||
classified.message = ""
|
||||
} else {
|
||||
classified.message = "The provider returned an error."
|
||||
}
|
||||
classified.message = "The provider returned an error."
|
||||
}
|
||||
return JSON.stringify(classified)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ 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"
|
||||
@@ -201,11 +200,8 @@ export async function listProviderModels(
|
||||
break
|
||||
}
|
||||
case "ollama": {
|
||||
const data = await getJson(
|
||||
`${ollamaApiUrl(base)}/tags`,
|
||||
bearer,
|
||||
fetchFn,
|
||||
)
|
||||
const api = base.endsWith("/api") ? base : `${base}/api`
|
||||
const data = await getJson(`${api}/tags`, bearer, fetchFn)
|
||||
models = (data.models ?? []).map((m: { name: string }) => ({
|
||||
id: m.name,
|
||||
}))
|
||||
|
||||
@@ -622,15 +622,6 @@ 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,
|
||||
@@ -639,7 +630,6 @@ 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[] = [
|
||||
@@ -647,6 +637,7 @@ export function chatRequestUrl(
|
||||
"vertexai",
|
||||
"azure",
|
||||
"bedrock",
|
||||
"ollama",
|
||||
"gateway",
|
||||
"minimax",
|
||||
"edgeone",
|
||||
|
||||
+2
-11
@@ -2,19 +2,10 @@
|
||||
* 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 ipHeader = process.env.CLIENT_IP_HEADER
|
||||
const rawIp =
|
||||
(ipHeader
|
||||
? req.headers.get(ipHeader)?.trim()
|
||||
: req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()) ||
|
||||
"anonymous"
|
||||
const forwardedFor = req.headers.get("x-forwarded-for")
|
||||
const rawIp = forwardedFor?.split(",")[0]?.trim() || "anonymous"
|
||||
return rawIp === "anonymous"
|
||||
? rawIp
|
||||
: `user-${Buffer.from(rawIp).toString("base64url")}`
|
||||
|
||||
Generated
+2505
-1290
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -67,6 +67,7 @@
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@xmldom/xmldom": "^0.9.8",
|
||||
"ai": "^6.0.300",
|
||||
"base-64": "^1.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -130,7 +131,7 @@
|
||||
"electron-builder": "^26.0.12",
|
||||
"esbuild": "^0.28.0",
|
||||
"eslint": "9.39.5",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"eslint-config-next": "16.3.8",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^27.4.0",
|
||||
"lint-staged": "^16.2.7",
|
||||
|
||||
Generated
+70
-102
@@ -507,12 +507,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.3.tgz",
|
||||
"integrity": "sha512-TA//nWMqPhbfdfneACk6t5a9eqbS9lABEPyKn0/xZTah3H3U2XaVg85rJFl0/Fyit0I552YDHgXGVSf3GwqbUw==",
|
||||
"version": "1.19.9",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
|
||||
"integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=18.14.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"hono": "^4"
|
||||
@@ -1045,9 +1045,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
@@ -1088,20 +1088,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz",
|
||||
"integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
"content-type": "^2.0.0",
|
||||
"content-type": "^1.0.5",
|
||||
"debug": "^4.4.3",
|
||||
"http-errors": "^2.0.1",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"http-errors": "^2.0.0",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"on-finished": "^2.4.1",
|
||||
"qs": "^6.15.2",
|
||||
"raw-body": "^3.0.2",
|
||||
"type-is": "^2.1.0"
|
||||
"qs": "^6.14.0",
|
||||
"raw-body": "^3.0.1",
|
||||
"type-is": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -1111,19 +1111,6 @@
|
||||
"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",
|
||||
@@ -1590,9 +1577,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"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==",
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
@@ -1743,13 +1730,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.7.0",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz",
|
||||
"integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==",
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
|
||||
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3",
|
||||
"ip-address": "^10.2.0"
|
||||
"ip-address": "10.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
@@ -1768,9 +1754,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -1926,9 +1912,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"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==",
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -1938,9 +1924,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.13.13",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz",
|
||||
"integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==",
|
||||
"version": "4.11.9",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz",
|
||||
"integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
@@ -2004,9 +1990,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz",
|
||||
"integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
@@ -2026,9 +2012,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.7.3",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz",
|
||||
"integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==",
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
|
||||
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
@@ -2493,9 +2479,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.20",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
|
||||
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2659,9 +2645,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "8.4.2",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
||||
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
|
||||
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -2705,9 +2691,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.29",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
|
||||
"integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2725,9 +2711,9 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.19",
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.2"
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
@@ -2759,13 +2745,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
|
||||
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.1",
|
||||
"side-channel": "^1.1.1"
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
@@ -2960,14 +2945,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.4",
|
||||
"side-channel-list": "^1.0.1",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
@@ -2979,13 +2964,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"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==",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.4"
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -3039,9 +3024,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"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==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
@@ -3152,34 +3137,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
||||
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"content-type": "^2.0.0",
|
||||
"content-type": "^1.0.5",
|
||||
"media-typer": "^1.1.0",
|
||||
"mime-types": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"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": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
|
||||
@@ -27,19 +27,9 @@ function getLocale(request: NextRequest): string | undefined {
|
||||
export function proxy(request: NextRequest) {
|
||||
const pathname = request.nextUrl.pathname
|
||||
|
||||
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
|
||||
// Skip API routes, static files, and Next.js internals
|
||||
if (
|
||||
pathname.startsWith("/api/") ||
|
||||
pathname.startsWith("/_next/") ||
|
||||
pathname.startsWith("/drawio") ||
|
||||
pathname.includes("/favicon") ||
|
||||
@@ -68,9 +58,6 @@ export function proxy(request: NextRequest) {
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// API routes (for ORIGIN_SECRET), and pages without `/_next/` assets
|
||||
matcher: [
|
||||
"/api/:path*",
|
||||
"/((?!api|_next/static|_next/image|favicon.ico).*)",
|
||||
],
|
||||
// Matcher ignoring `/_next/` and `/api/`
|
||||
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
|
||||
}
|
||||
|
||||
@@ -75,30 +75,3 @@ test("a provider rate limit is not shown as this site's quota", async ({
|
||||
// The site's own tokens-per-minute toast
|
||||
await expect(page.getByText("Rate limit reached")).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("a refused server key shows only the quota hint and a settings button", async ({
|
||||
page,
|
||||
}) => {
|
||||
// What the chat route streams when the server's key gets a 403, e.g.
|
||||
// after a daily spend cap blocked it
|
||||
const errorText = JSON.stringify({
|
||||
type: "provider",
|
||||
code: "server_key_forbidden",
|
||||
message: "",
|
||||
})
|
||||
await chatWith(page, {
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: `data: {"type":"start"}\n\ndata: ${JSON.stringify({ type: "error", errorText })}\n\ndata: [DONE]\n\n`,
|
||||
})
|
||||
await expect(
|
||||
page.getByText("Today's free quota is used up", { exact: false }),
|
||||
).toBeVisible({ timeout: 15000 })
|
||||
await expect(page.getByText("The provider returned an error")).toHaveCount(
|
||||
0,
|
||||
)
|
||||
await page.getByRole("button", { name: "Open model settings" }).click()
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "AI Model Configuration" }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -154,9 +154,9 @@ describe("isSettingsWritable", () => {
|
||||
expect(isSettingsWritable()).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false when the settings path is a directory", () => {
|
||||
it("returns false for an unwritable path", () => {
|
||||
_resetForTests()
|
||||
process.env.SETTINGS_FILE = tmpDir
|
||||
process.env.SETTINGS_FILE = "/nonexistent-root-dir/settings.json"
|
||||
expect(isSettingsWritable()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -170,9 +170,7 @@ describe("settings file on disk", () => {
|
||||
version: 1,
|
||||
values: { TEST_ADMIN_VAR: "secret" },
|
||||
})
|
||||
if (process.platform !== "win32") {
|
||||
const mode = fs.statSync(filePath).mode & 0o777
|
||||
expect(mode).toBe(0o600)
|
||||
}
|
||||
const mode = fs.statSync(filePath).mode & 0o777
|
||||
expect(mode).toBe(0o600)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -434,7 +434,7 @@ describe("Ollama API key security", () => {
|
||||
|
||||
expect(createOllamaMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: "https://my-ollama.com/api",
|
||||
baseURL: "https://my-ollama.com",
|
||||
headers: { Authorization: "Bearer client-key" },
|
||||
}),
|
||||
)
|
||||
@@ -448,7 +448,7 @@ describe("Ollama API key security", () => {
|
||||
|
||||
expect(createOllamaMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: "https://cloud.ollama.com/api",
|
||||
baseURL: "https://cloud.ollama.com",
|
||||
headers: { Authorization: "Bearer server-key" },
|
||||
}),
|
||||
)
|
||||
@@ -483,24 +483,7 @@ describe("Ollama API key security", () => {
|
||||
|
||||
expect(createOllamaMock).toHaveBeenCalledTimes(1)
|
||||
const callArgs = createOllamaMock.mock.calls[0][0]
|
||||
expect(callArgs.baseURL).toBe("https://my-ollama.com/api")
|
||||
expect(callArgs.baseURL).toBe("https://my-ollama.com")
|
||||
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",
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import {
|
||||
chatRequestUrl,
|
||||
normalizeBaseUrl,
|
||||
ollamaApiUrl,
|
||||
} from "@/lib/types/model-config"
|
||||
import { chatRequestUrl, normalizeBaseUrl } from "@/lib/types/model-config"
|
||||
|
||||
describe("normalizeBaseUrl", () => {
|
||||
it("drops spaces, trailing slashes and a pasted endpoint path", () => {
|
||||
@@ -36,9 +32,6 @@ 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", () => {
|
||||
@@ -47,21 +40,3 @@ 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",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -139,8 +139,7 @@ describe("provider error texts in the stream", () => {
|
||||
)
|
||||
const message = await streamedError({})
|
||||
expect(message).not.toMatch(/org-operator/)
|
||||
// A 403 on the server's key gets its own hint and no message
|
||||
expect(message).toBe("")
|
||||
expect(message).toBe("The provider returned an error.")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -215,29 +215,11 @@ describe("streamErrorText", () => {
|
||||
"User: arn:aws:sts::123456789012:assumed-role/app/s is not authorized to perform: bedrock:InvokeModel",
|
||||
)
|
||||
const hidden = JSON.parse(streamErrorText(error, true))
|
||||
expect(hidden.code).toBe("forbidden")
|
||||
expect(hidden.message).not.toMatch(/arn:aws|123456789012/)
|
||||
expect(JSON.parse(streamErrorText(error)).message).toMatch(
|
||||
/not authorized/,
|
||||
)
|
||||
const throttled = JSON.parse(
|
||||
streamErrorText(apiError(429, "Too many tokens"), true),
|
||||
)
|
||||
expect(throttled).toEqual({
|
||||
type: "provider",
|
||||
code: "rate_limited",
|
||||
message: "The provider returned an error.",
|
||||
})
|
||||
})
|
||||
|
||||
it("names a 403 on the server's keys, e.g. a spend cap blocked them", () => {
|
||||
const error = apiError(403, "explicit deny in an identity-based policy")
|
||||
expect(JSON.parse(streamErrorText(error, true))).toEqual({
|
||||
type: "provider",
|
||||
code: "server_key_forbidden",
|
||||
message: "",
|
||||
})
|
||||
// On the user's own key it stays a plain refusal
|
||||
expect(JSON.parse(streamErrorText(error)).code).toBe("forbidden")
|
||||
})
|
||||
|
||||
it("classifies a provider error", () => {
|
||||
|
||||
Reference in New Issue
Block a user