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
Bryon Nevis 38b72e89b6 feat: hide the MCP server card when NEXT_PUBLIC_SELFHOSTED is true (#711)
The welcome title and the Quick examples heading stay.
2026-10-06 08:38:33 +09:00
Semianchuk Vitalii 6e77b08eb3 chore: remove the unused base-64 dependency and list all providers in env.example (#881)
Keep the viewport settings so iOS Safari does not zoom on input focus.
2026-10-06 08:38:27 +09:00
renovate[bot] 3855ff2b02 chore(deps): update dependency electron-builder to v26.15.3 (#856)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-06 08:21:32 +09:00
Ngo Quoc Viet 668f79c0ab fix: validate admin settings file target (#894) 2026-10-06 08:21:27 +09:00
Dayuan Jiang 75be7cb3ea Merge pull request #326 from JoeGlenn1213/fix/deepseek-skip-images
fix: skip image parts for DeepSeek to avoid unknown variant "image_url"
2026-10-06 08:10:36 +09:00
Dayuan Jiang 29736bb528 Merge pull request #795 from octo-patch/fix/issue-644-mcp-png-export-corrupt
fix: prevent SVG data from being misidentified as PNG in MCP export
2026-10-06 08:10:30 +09:00
Dayuan Jiang 73aa6a45f6 Merge pull request #820 from octo-patch/fix/issue-819-azure-endpoint-construction
fix: use createAzure in validate-model and fix resourceName fallback
2026-10-06 08:10:26 +09:00
Dayuan Jiang 3c9bcfb978 Merge pull request #884 from chaochaoweb3/codex/model-validation-max-tokens
fix: increase model validation token budget
2026-10-06 08:10:21 +09:00
Dayuan Jiang ba2a789d20 Merge pull request #892 from mvanhorn/fix/861-bedrock-claude-temperature-deprecated
fix: skip temperature parameter for models that reject it
2026-10-06 08:10:16 +09:00
Dayuan Jiang 1a63498d5d Merge pull request #513 from xinyang20/feature/add-more-themes
feat: add all Draw.io themes to settings panel (#499)
2026-10-06 08:10:11 +09:00
dayuan.jiang dee0c80b3e Merge branch 'main' into fix/deepseek-skip-images
Keep main's version of every file. Main now covers this fix,
so this PR can be merged with its commits in the history.
2026-10-06 08:03:04 +09:00
dayuan.jiang 4612f7c38d Merge branch 'main' into fix/issue-644-mcp-png-export-corrupt
Keep main's version of every file. Main now covers this fix,
so this PR can be merged with its commits in the history.
2026-10-06 08:03:04 +09:00
dayuan.jiang f3a4c98f26 Merge branch 'main' into fix/issue-819-azure-endpoint-construction
Keep main's version of every file. Main now covers this fix,
so this PR can be merged with its commits in the history.
2026-10-06 08:03:04 +09:00
dayuan.jiang 6857633105 Merge branch 'main' into codex/model-validation-max-tokens
Keep main's version of every file. Main now covers this fix,
so this PR can be merged with its commits in the history.
2026-10-06 08:03:04 +09:00
dayuan.jiang 57729cf174 Merge branch 'main' into fix/861-bedrock-claude-temperature-deprecated
Keep main's version of every file. Main now covers this fix,
so this PR can be merged with its commits in the history.
2026-10-06 08:03:04 +09:00
dayuan.jiang 9c46e760af Merge branch 'main' into feature/add-more-themes
Keep main's version of every file. Main now covers this fix,
so this PR can be merged with its commits in the history.
2026-10-06 08:03:04 +09:00
Dayuan Jiang 21ab839be7 Merge pull request #920 from NgoQuocViet2001/fix-server-model-id-collisions
fix: prevent server model ID collisions
2026-10-06 07:24:30 +09:00
Dayuan Jiang 23b76c098b Merge pull request #934 from xiajiadi/fix/deepseek-v4-vision-image-input
fix: support DeepSeek V4 Vision image input
2026-10-06 07:24:26 +09:00
Dayuan Jiang a87ada3776 Merge pull request #945 from DawnSouther/fix/mcp-export-queue
fix(mcp): queue png/svg exports by job id so concurrent exports no longer time out
2026-10-06 07:24:21 +09:00
dayuan.jiang 4c8d8427bc Merge branch 'main' into fix-server-model-id-collisions
Keep main's version of every file. The fix landed in main through #951,
so this PR can be merged with its commits in the history.
2026-10-06 07:14:15 +09:00
dayuan.jiang d47c5ebb4e Merge branch 'main' into fix/deepseek-v4-vision-image-input
Keep main's version of every file. The fix landed in main through #951,
so this PR can be merged with its commits in the history.
2026-10-06 07:14:15 +09:00
dayuan.jiang 52b1f24447 Merge branch 'main' into fix/mcp-export-queue
Keep main's version of every file. The fix landed in main through #951,
so this PR can be merged with its commits in the history.
2026-10-06 07:14:15 +09:00
Dayuan Jiang 7fccf68edf fix(mcp-server): read npm 12's pack output in the package check (#952)
The publish workflow installs the latest npm, and npm 12 prints
`npm pack --json` as an object keyed by package name instead of a list,
so check-package failed and 0.3.0 was not published. Accept both.
2026-10-05 22:58:56 +09:00
dawn-book 113608ec02 fix(mcp): queue png/svg exports by job id so concurrent exports no longer time out
The old export protocol used a single state slot per session: the MCP tool
set exportFormat/exportXml, the browser bridge picked it up on its 2s poll,
and the rendered image came back as one exportData field that whichever
tool call polled first consumed and cleared. Under concurrency this meant:
requests overwrote each other, at most one of N parallel exports succeeded,
a late render could satisfy the next export with the WRONG image, the
browser's leaked 10s fallback timer could silently swallow a successor's
request, and a closed preview tab made every export hang to timeout.

Replace it with a per-session export job queue:

- each export_diagram call enqueues a job (unique id, format, optional
  single-page projection) and awaits its own job's promise
- GET /api/state exposes only the head of the queue; the browser renders
  jobs strictly one at a time and reports each result/failure BY JOB ID,
  which resolves exactly the waiting call; stale ids are ignored
- browser-side fallback timer (30s) is tracked and cleared per job and
  only ever fails its own job, so failures advance the queue
- server-side per-job backstop is 120s (DRAWIO_EXPORT_TIMEOUT_MS) since a
  queued job also waits for its predecessors
- browser heartbeat (20s staleness) fails enqueues/waiters fast when the
  preview tab is gone, instead of hanging to the timeout
- expired-session cleanup now fails waiting jobs and drops queue state

Add tests/export-queue.test.ts covering serialization, per-job routing,
stale-result rejection, fail/timeout queue advance, and the untouched
autosave paths.
2026-09-27 08:30:49 +08:00
xiajiadi 58b73ef761 fix: support DeepSeek Vision image input 2026-09-04 17:07:05 +08:00
xiajiadi ea16ea1ece test: cover DeepSeek Vision image payload 2026-09-04 17:07:04 +08:00
NgoQuocViet2001 e355a50890 fix: disambiguate colliding server model IDs 2026-08-14 15:36:42 +07:00
chaochaoweb3 42ab388118 refactor: hardcode model validation token budget 2026-07-20 17:08:10 +08:00
Matt Van Horn dfedd1b881 chore: drop workspace metadata 2026-07-12 01:47:10 -07:00
Matt Van Horn 29acbc33b8 fix: address round-2 residual (surgical round) 2026-07-12 01:29:26 -07:00
Matt Van Horn d6bdb25f58 fix: address self-review findings 2026-07-12 01:26:59 -07:00
dayuan.jiang eb2fa69f58 refactor: drop request-body token override, keep env-only config
The unauthenticated /api/validate-model endpoint should not let callers
raise the token budget; the env var / admin setting alone fixes #883.
Also import the shared constants in the settings registry instead of
duplicating them, and move the setting to the Features group alongside
the other validation settings.
2026-07-11 22:30:20 +09:00
chaochaoweb3 c1e4b1c9bf fix: make model validation token budget configurable 2026-07-02 15:48:44 +08:00
octo-patch d08b28821e fix: use createAzure in validate-model and fix resourceName fallback (fixes #819)
Two related bugs caused Azure OpenAI to pass validation but fail in actual use:

1. validate-model/route.ts used createOpenAI for Azure, while ai-providers.ts
   uses createAzure. These differ in URL construction and authentication headers
   (api-key vs Authorization: Bearer), so validation did not test the real code
   path. Switch to createAzure for consistency.

2. In ai-providers.ts, when a user provided their own API key without a custom
   base URL, resourceName was unconditionally set to undefined. This left
   createAzure with no endpoint information, resulting in resource not found.
   resourceName is an endpoint component, not a credential, so it is safe to
   fall back to AZURE_RESOURCE_NAME from the environment whenever no baseURL is
   available.
2026-04-22 20:07:28 +08:00
Octopus 8986217a73 fix: prevent SVG data from being misidentified as PNG in MCP export (fixes #644)
The fallback condition for PNG detection in the MCP export handler was too broad:
it would match any string longer than 100 chars that didn't start with '<', which
includes SVG data URLs (data:image/svg+xml;base64,...).

This caused a race condition where an autosave SVG export response could arrive
while a PNG export was pending, resulting in SVG data being sent to the MCP server
as the PNG export result. The server would then try to write it as binary PNG,
producing a corrupt file (broken image).

Fix: exclude strings starting with 'data:' from the fallback condition, so only
raw base64 strings (without a data URL prefix) match as PNG, while SVG data URLs
are correctly rejected.
2026-04-07 09:44:27 +08:00
Octopus 73788abff9 fix: remove redundant status(modified:false) call to restore undo/redo (fixes #779) 2026-04-04 09:54:20 +08:00
Xinyang Gao-PC de0ba6b718 Merge branch 'feature/add-more-themes' of github.com:xinyang20/next-ai-draw-io into feature/add-more-themes 2026-01-12 22:19:38 +08:00
Xinyang Gao-PC a8ff1ea2bc fix:add missing DRAWIO_THEMES import in settings-dialog 2026-01-12 22:17:18 +08:00
Xinyang Gao b723406a48 Merge branch 'main' into feature/add-more-themes 2026-01-12 22:15:40 +08:00
Xinyang Gao-PC 783cb4a6f1 fix:resolve the Copilot-suggested changes 2026-01-04 20:13:47 +08:00
Xinyang Gao-PC 801974a18b feat: add all Draw.io themes to settings panel (#499) 2026-01-04 19:41:49 +08:00
Your Name 5f71b2783f style: enhance UI with modern glassmorphism, animations and refined spacing 2025-12-19 15:11:42 +08:00
Your Name 691c7e34bf feat: image capability detection and user-friendly errors; warn and filter images for unsupported models 2025-12-19 14:12:31 +08:00
Your Name 0d50a179aa fix: skip image parts for DeepSeek to avoid unknown variant "image_url" 2025-12-19 12:07:06 +08:00
27 changed files with 1663 additions and 2618 deletions
+24 -21
View File
@@ -77,6 +77,7 @@ export default function ExamplePanel({
minimal?: boolean
}) {
const dict = useDictionary()
const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === "true"
const handleReplicateFlowchart = async () => {
setInput("Replicate this flowchart.")
@@ -125,29 +126,31 @@ export default function ExamplePanel({
<div className={minimal ? "" : "py-6 px-2 animate-fade-in"}>
{!minimal && (
<>
{/* 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>
<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>
{/* 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>
<p className="text-xs text-muted-foreground">
{dict.examples.mcpDescription}
</p>
</div>
</div>
</a>
</a>
)}
{/* Welcome section */}
<div className="text-center mb-6">
+6 -3
View File
@@ -427,13 +427,16 @@ export default function ChatPanel({
let openModelConfig = false
if (data?.type === "provider") {
const hints = dict.errors.llm as Record<string, string>
text = hints[data.code]
? `${hints[data.code]}\n\n${data.message}`
: data.message
const hint = hints[data.code]
text =
hint && data.message
? `${hint}\n\n${data.message}`
: hint || 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
+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
+13 -2
View File
@@ -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, gateway, novita
# 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
# 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)
# 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,11 +163,22 @@ 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)
+6 -1
View File
@@ -125,7 +125,12 @@ let writableCache: boolean | null = null
export function isSettingsWritable(): boolean {
if (writableCache !== null) return writableCache
try {
const dir = path.dirname(getSettingsPath())
const filePath = getSettingsPath()
if (fs.existsSync(filePath) && !fs.statSync(filePath).isFile()) {
writableCache = false
return writableCache
}
const dir = path.dirname(filePath)
fs.mkdirSync(dir, { recursive: true })
fs.accessSync(dir, fs.constants.W_OK)
writableCache = true
+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}` },
}),
+1
View File
@@ -192,6 +192,7 @@
"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.",
+1
View File
@@ -192,6 +192,7 @@
"llm": {
"invalid_api_key": "プロバイダーが API キーを拒否しました。モデル設定で確認してください。",
"forbidden": "プロバイダーがリクエストを拒否しました。このキーにはこのモデルまたはリージョンの利用権限がない可能性があります。",
"server_key_forbidden": "本日の無料枠を使い切りました。明日になると自動的に回復します。モデル設定でご自身の API キーを入力すると、引き続きご利用いただけます。",
"model_not_found": "プロバイダーがこのモデルを認識できません。モデル設定でモデル ID を確認してください。",
"insufficient_quota": "プロバイダーのアカウントの残高または利用枠がなくなりました。",
"rate_limited": "プロバイダーがリクエスト数を制限しています。少し待ってから再試行してください。",
+1
View File
@@ -192,6 +192,7 @@
"llm": {
"invalid_api_key": "服務商拒絕了這個 API Key,請在模型設定中檢查。",
"forbidden": "服務商拒絕了這次請求。這個 Key 可能沒有使用該模型或該地區的權限。",
"server_key_forbidden": "今天的免費額度已經用完,明天會自動恢復。您也可以在模型設定中填寫自己的 API Key 繼續使用。",
"model_not_found": "服務商找不到這個模型,請在模型設定中檢查模型 ID。",
"insufficient_quota": "服務商帳戶的餘額或額度已經用完。",
"rate_limited": "服務商正在限制請求頻率,請稍候再試。",
+1
View File
@@ -192,6 +192,7 @@
"llm": {
"invalid_api_key": "服务商拒绝了这个 API Key,请在模型设置里检查。",
"forbidden": "服务商拒绝了这次请求。这个 Key 可能没有使用该模型或该地区的权限。",
"server_key_forbidden": "今天的免费额度已经用完,明天会自动恢复。您也可以在模型设置里填写自己的 API Key 继续使用。",
"model_not_found": "服务商找不到这个模型,请在模型设置里检查模型 ID。",
"insufficient_quota": "服务商账户的余额或额度已经用完。",
"rate_limited": "服务商正在限制请求频率,请稍等片刻再试。",
+9 -1
View File
@@ -24,6 +24,8 @@ 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 {
@@ -130,7 +132,13 @@ export function streamErrorText(error: unknown, hideDetails = false): string {
const classified = classifyLLMError(error)
if (hideDetails) {
console.error("[chat] Provider error:", error)
classified.message = "The provider returned an 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."
}
}
return JSON.stringify(classified)
}
+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")}`
+1281 -2496
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -67,7 +67,6 @@
"@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",
+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).*)",
],
}
+27
View File
@@ -75,3 +75,30 @@ 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()
})
+6 -4
View File
@@ -154,9 +154,9 @@ describe("isSettingsWritable", () => {
expect(isSettingsWritable()).toBe(true)
})
it("returns false for an unwritable path", () => {
it("returns false when the settings path is a directory", () => {
_resetForTests()
process.env.SETTINGS_FILE = "/nonexistent-root-dir/settings.json"
process.env.SETTINGS_FILE = tmpDir
expect(isSettingsWritable()).toBe(false)
})
})
@@ -170,7 +170,9 @@ describe("settings file on disk", () => {
version: 1,
values: { TEST_ADMIN_VAR: "secret" },
})
const mode = fs.statSync(filePath).mode & 0o777
expect(mode).toBe(0o600)
if (process.platform !== "win32") {
const mode = fs.statSync(filePath).mode & 0o777
expect(mode).toBe(0o600)
}
})
})
+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",
)
})
})
+2 -1
View File
@@ -139,7 +139,8 @@ describe("provider error texts in the stream", () => {
)
const message = await streamedError({})
expect(message).not.toMatch(/org-operator/)
expect(message).toBe("The provider returned an error.")
// A 403 on the server's key gets its own hint and no message
expect(message).toBe("")
})
})
+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()
})
})
+19 -1
View File
@@ -215,11 +215,29 @@ 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", () => {