Compare commits

..

15 Commits

Author SHA1 Message Date
dayuan.jiang
2cc64bbe5f fix(admin): address follow-up Copilot findings on the prior fixes
- loadAdminProviders now validates against a stored-shape schema where
  secrets are plain strings, so a hand-edited ADMIN_PROVIDERS holding an
  {isSet} marker is dropped instead of later crashing maskSecret().
- loadSettings guards against array values (typeof [] === 'object'),
  which would otherwise overlay numeric keys onto process.env.
- Admin SecretInput uses the bare id so the shared component's
  <Label htmlFor> stays associated (only one ProviderDetail mounts).
- Add tests: marker-secret rejection, array-values guard, bedrock
  multi-secret round-trip.
2026-06-15 00:16:01 +09:00
dayuan.jiang
ee4613c388 docs: move admin panel details to dedicated docs/{en,cn,ja}/admin-panel.md
The READMEs now carry a short blurb + link, matching the existing
per-topic docs (docker.md, ai-providers.md, ...). Removes the ~22-line
inline section and the duplicated data/settings.json mentions.
2026-06-12 11:44:25 +09:00
dayuan.jiang
f96a0c5a18 fix(admin): address Copilot review findings
- Reflect built-in defaults for boolean settings (ALLOW_PRIVATE_URLS
  defaults on) and allow clearing a saved boolean back to default,
  so the SSRF toggle matches actual runtime behavior.
- Harden JSON loading: filter settings values to strings only, and
  schema-validate stored ADMIN_PROVIDERS entries, dropping malformed
  ones instead of letting them reach runtime code.
- Set beforeunload returnValue so the unsaved-changes prompt shows in
  all browsers; reject non-finite numbers in settings validation.
- Fix README/CN/JA docs that claimed the panel auto-generates
  AI_MODELS_CONFIG (providers are merged at read time, not written).
- Add unit tests for corrupted-file value filtering and provider
  schema validation.
2026-06-12 11:34:11 +09:00
dayuan.jiang
3764b9b620 feat(admin): share credential fields with user dialog and localize panel
Extract ProviderCredentialsFields (display name + per-provider
credential inputs) used by both the user ModelConfigDialog and the
admin Models panel; secret input passed via renderSecret (plaintext
vs masked), test button via footer slot. Add full i18n for the admin
panel across en/zh/ja/zh-Hant, reusing modelConfig.* for shared parts.
2026-06-12 10:16:17 +09:00
dayuan.jiang
0753518086 refactor(admin): split 1549-line admin page into focused modules
Extract admin-shared.ts (types + fetch helper), setting-field.tsx
(registry-driven fields), and models-section.tsx (provider/model
manager) from page.tsx. Pure mechanical move, no behavior change.
2026-06-12 10:16:08 +09:00
dayuan.jiang
d208dc70a4 fix: address admin panel review findings
- Security: test-model no longer resolves a stored secret when the
  request's baseUrl/provider differs from the stored entry, closing a
  path where a tampered baseUrl could exfiltrate a saved key
- Save failures are now visible: the save bar shows the error in red
  (was masked by the persistent 'Unsaved changes' text), and per-field
  validation errors from the settings API are surfaced under each field
- The Observability/Quota enable switch is now real: toggling off stages
  deletion of the group's saved values, and the toggle no longer snaps
  back to Enabled after saving
- Env provider's default star is hidden when a panel provider is the
  active default (no more double star)
- Clearing a credential field reverts to the stored value instead of
  silently deleting it; an explicit X button removes a stored secret
- Form inputs are disabled during an in-flight save
2026-06-12 08:13:26 +09:00
dayuan.jiang
b0a96c23a3 fix: block global-credential providers already managed via env
Bedrock, Vertex AI, and Ollama credentials live in fixed env vars with
no apiKeyEnv redirection, so a panel instance of one of these would
silently override the credentials that env-configured models rely on.
The API now rejects saving such a provider when the env config already
uses that type, and the Add Provider dropdown disables it with a
'managed via env' note.
2026-06-11 18:08:02 +09:00
dayuan.jiang
8ca628a872 fix: merge env AI_MODELS_CONFIG with admin panel providers
Previously, saving in the admin panel wrote a complete AI_MODELS_CONFIG
into settings.json, which (by overlay precedence) replaced any config
from .env or ai-models.json — admins lost their env-configured models.

The panel no longer writes AI_MODELS_CONFIG. Instead its providers are
merged with the env baseline at read time in loadRawServerModelsConfig,
and panel credentials go to ADMIN_-prefixed env vars wired up via
apiKeyEnv/baseUrlEnv so they never shadow standard vars. Env-based
providers now appear read-only in the panel, name clashes are rejected,
and a panel default overrides the env default. data/ is now gitignored.
2026-06-11 13:44:38 +09:00
dayuan.jiang
a90a87355b fix: allow testing unsaved providers in admin panel
The test button previously looked up credentials by providerId in the
saved settings, so testing a newly added (unsaved) provider failed with
'Unknown provider or model'. The test endpoint now accepts the client's
current provider state; newly typed secrets are used as-is and masked
markers are resolved against the stored values, so testing works both
before and after saving.
2026-06-11 13:14:05 +09:00
dayuan.jiang
e4a14c3628 feat: graphical model management in admin panel
Replace the provider credential fields and raw AI_MODELS_CONFIG JSON
textarea with a Models section mirroring the in-app model settings UI:
provider instance list with logos, credential fields per provider type,
model add/remove with suggestions, per-model connectivity test, and a
default-provider star.

On save the server derives everything the runtime needs into
settings.json: credential env vars (with _2 suffixes for multiple
instances of one provider), AI_MODELS_CONFIG, and AI_PROVIDER/AI_MODEL
for the default. Secrets round-trip as masked markers and are never
sent back to the browser. The general settings registry now only
covers non-provider settings (generation, access, features,
observability, quota).
2026-06-10 23:15:18 +09:00
dayuan.jiang
9feab38a0b refactor: derive admin registry from PROVIDER_INFO, simplify page state
- Provider options, labels, and base-URL placeholders now come from
  PROVIDER_INFO instead of hand-copied lists (fixes SiliconFlow .com/.cn
  placeholder drift; panel names now match the model-config dialog)
- Replace free-text subgroup strings + SUBGROUP_PROVIDERS reverse map
  with a typed provider field on SettingDef
- Precompute SETTINGS_BY_GROUP and PROVIDER_SUBGROUPS at module level
- Merge justSaved into saveMessage, drop unused mainRef, hoist
  fetchSettings out of the component, dedupe savedText logic
- Serialize from SETTINGS_REGISTRY directly; json validators in a map
  instead of a hardcoded key check
- Make allowPrivateUrls a function so ALLOW_PRIVATE_URLS edits in the
  admin panel apply without restart
2026-06-10 21:39:27 +09:00
dayuan.jiang
c09b454105 polish: make section enable switch more visible
Wrap the switch in a labeled pill ('Enabled'/'Disabled') with border
and background so the off state is clearly visible.
2026-06-10 20:58:58 +09:00
dayuan.jiang
9d958ae9c9 polish: admin panel section toggles and reorder
- Move Quota & Rate Limits to the end of the settings page
- Add enable switches to Observability and Quota sections; default off
  with fields grayed out, auto-on when any field is already configured
2026-06-10 20:54:25 +09:00
dayuan.jiang
d748e94262 polish: admin panel UI improvements
- Provider logos in credential rows (shared ProviderLogo component,
  extracted from model-config-dialog)
- Scroll-spy active state in the sidebar nav
- Green success state in the save bar that clears after a few seconds
- Wider content column (max-w-6xl) for less wasted space on desktop
2026-06-10 20:47:52 +09:00
dayuan.jiang
e2e499e5c7 feat: add file-based admin settings panel at /admin
Settings saved in the panel are written to data/settings.json and
overlaid onto process.env, taking precedence over environment
variables and applying immediately without restart. Enable by setting
ADMIN_PASSWORD; on serverless platforms without persistent disk the
panel degrades to read-only.
2026-06-10 20:41:17 +09:00
33 changed files with 369 additions and 4386 deletions

View File

@@ -23,9 +23,7 @@ jobs:
node-version: '24'
- name: Run Biome format
# Pin to the version in package.json so CI matches local/pre-commit
# (npx @latest drifts — e.g. 2.5.0 broke this job on unrelated PRs).
run: npx @biomejs/biome@2.4.13 check --write --no-errors-on-unmatched .
run: npx @biomejs/biome@latest check --write --no-errors-on-unmatched .
- name: Check for changes
id: changes

View File

@@ -28,16 +28,6 @@ jobs:
- name: Run unit tests
run: npm run test -- --run
# The MCP server package ships its own vitest because its DOM polyfill
# (linkedom) needs `environment: node`, while the root vitest uses jsdom
# for the Next.js app. Install + run its tests separately so CI catches
# multi-page mxfile regressions.
- name: Install MCP server dependencies
run: npm --prefix packages/mcp-server ci
- name: Run MCP server unit tests
run: npm --prefix packages/mcp-server test
e2e:
name: E2E Tests
runs-on: ubuntu-latest

View File

@@ -211,7 +211,6 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
- Azure OpenAI
- Ollama
- OpenRouter
- AIHubMix
- DeepSeek
- SiliconFlow
- ModelScope
@@ -225,7 +224,7 @@ All providers except AWS Bedrock and OpenRouter support custom endpoints.
### Server-Side Multi-Model Configuration
Administrators can configure multiple server-side models that are available to all users without requiring personal API keys. Configure via `AI_MODELS_CONFIG` environment variable (JSON string) or `ai-models.json` file. For a single-provider quick setup, list comma-separated model IDs in `AI_MODEL`.
Administrators can configure multiple server-side models that are available to all users without requiring personal API keys. Configure via `AI_MODELS_CONFIG` environment variable (JSON string) or `ai-models.json` file.
### Admin Panel

View File

@@ -1,61 +0,0 @@
import { NextResponse } from "next/server"
import {
AIHUBMIX_MODELS_ENDPOINT,
extractAihubmixModelIds,
} from "@/lib/aihubmix-models"
import { SUGGESTED_MODELS } from "@/lib/types/model-config"
const SUCCESS_CACHE_CONTROL =
"public, max-age=300, s-maxage=3600, stale-while-revalidate=86400"
function fallbackResponse() {
return NextResponse.json(
{
models: SUGGESTED_MODELS.aihubmix || [],
source: "fallback",
},
{
headers: {
"Cache-Control": "no-store",
},
},
)
}
export async function GET() {
try {
const response = await fetch(AIHUBMIX_MODELS_ENDPOINT, {
next: { revalidate: 3600 },
})
if (!response.ok) {
console.warn(
`[aihubmix-models] Failed to fetch models: ${response.status}`,
)
return fallbackResponse()
}
const payload = await response.json()
const models = extractAihubmixModelIds(payload)
if (models.length === 0) {
console.warn("[aihubmix-models] Model list response was empty")
return fallbackResponse()
}
return NextResponse.json(
{
models,
source: "aihubmix",
},
{
headers: {
"Cache-Control": SUCCESS_CACHE_CONTROL,
},
},
)
} catch (error) {
console.warn("[aihubmix-models] Failed to load models:", error)
return fallbackResponse()
}
}

View File

@@ -1,4 +1,4 @@
import { extractFromHtml } from "@extractus/article-extractor"
import { extract } from "@extractus/article-extractor"
import { NextResponse } from "next/server"
import TurndownService from "turndown"
import { isPrivateUrl } from "@/lib/ssrf-protection"
@@ -7,31 +7,6 @@ const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
const EXTRACT_TIMEOUT_MS = 15000
const USER_AGENT = "Mozilla/5.0 (compatible; NextAIDrawio/1.0)"
// Detect the page's charset so non-UTF-8 pages (Shift_JIS/GBK/EUC/Big5, common
// on CJK sites) are decoded correctly. Response.text() always assumes UTF-8 and
// would produce mojibake; the article-extractor library does the same detection
// when it fetches the page itself, which we no longer rely on.
function detectCharset(
contentType: string | null,
buffer: ArrayBuffer,
): string {
// 1. HTTP Content-Type header charset (most authoritative).
const headerCharset = contentType?.match(/charset=([^;]+)/i)?.[1]?.trim()
// 2. <meta charset> / <meta http-equiv> in the first bytes of the document.
const head = new TextDecoder("utf-8").decode(buffer.slice(0, 4096))
const metaCharset =
head.match(/<meta[^>]+charset=["']?\s*([\w-]+)/i)?.[1] ||
head.match(/<meta[^>]+content=["'][^"']*charset=([\w-]+)/i)?.[1]
const charset = (headerCharset || metaCharset || "utf-8").toLowerCase()
// TextDecoder throws on unknown encoding labels; fall back to UTF-8.
try {
new TextDecoder(charset)
return charset
} catch {
return "utf-8"
}
}
export async function POST(req: Request) {
try {
const { url } = await req.json()
@@ -56,31 +31,21 @@ export async function POST(req: Request) {
// SSRF protection: parse-url has no use case for fetching internal
// hosts, so private URLs are always rejected. ALLOW_PRIVATE_URLS only
// governs LLM provider baseUrl overrides (validate-model, chat).
if (await isPrivateUrl(url)) {
if (isPrivateUrl(url)) {
return NextResponse.json(
{ error: "Cannot access private/internal URLs" },
{ status: 400 },
)
}
// Fetch the page ourselves so we control redirect handling. The
// article-extractor library follows redirects internally and ignores a
// `redirect` option, which would let a public URL 302 to an internal
// host and bypass the SSRF check above. `redirect: "error"` rejects any
// redirect outright.
const controller = new AbortController()
const timeoutId = setTimeout(() => {
controller.abort()
}, EXTRACT_TIMEOUT_MS)
let html: string
const headController = new AbortController()
const headTimeout = setTimeout(() => headController.abort(), 3000)
try {
const response = await fetch(url, {
const headResponse = await fetch(url, {
method: "HEAD",
headers: { "User-Agent": USER_AGENT },
redirect: "error",
signal: controller.signal,
signal: headController.signal,
})
const contentType = response.headers.get("content-type")
const contentType = headResponse.headers.get("content-type")
if (contentType?.includes("application/pdf")) {
return NextResponse.json(
{
@@ -89,17 +54,27 @@ export async function POST(req: Request) {
{ status: 422 },
)
}
} catch (err) {
console.warn(
"HEAD pre-check failed, proceeding with extraction:",
err,
)
} finally {
clearTimeout(headTimeout)
}
if (!response.ok) {
return NextResponse.json(
{ error: "Could not fetch URL content" },
{ status: 400 },
)
}
// Extract article content with timeout to avoid tying up server resources
const controller = new AbortController()
const timeoutId = setTimeout(() => {
controller.abort()
}, EXTRACT_TIMEOUT_MS)
const buffer = await response.arrayBuffer()
const charset = detectCharset(contentType, buffer)
html = new TextDecoder(charset).decode(buffer)
let article
try {
article = await extract(url, undefined, {
headers: { "User-Agent": USER_AGENT },
signal: controller.signal,
})
} catch (err: any) {
if (err?.name === "AbortError") {
return NextResponse.json(
@@ -107,25 +82,11 @@ export async function POST(req: Request) {
{ status: 504 },
)
}
// Redirects are rejected with a TypeError ("failed to fetch" /
// "unexpected redirect") when redirect: "error" is set.
return NextResponse.json(
{ error: "Could not fetch URL content" },
{ status: 400 },
)
throw err
} finally {
clearTimeout(timeoutId)
}
// extractFromHtml throws (not returns null) on empty/non-HTML bodies,
// so map any parse error to the same 400 as the no-content case.
let article: Awaited<ReturnType<typeof extractFromHtml>>
try {
article = await extractFromHtml(html, url)
} catch {
article = null
}
if (!article || !article.content) {
return NextResponse.json(
{ error: "Could not extract content from URL" },

View File

@@ -5,16 +5,11 @@ import { createGateway } from "@ai-sdk/gateway"
import { createGoogleGenerativeAI } from "@ai-sdk/google"
import { createVertex } from "@ai-sdk/google-vertex"
import { createOpenAI } from "@ai-sdk/openai"
import { createAihubmix } from "@aihubmix/ai-sdk-provider"
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { generateText } from "ai"
import { NextResponse } from "next/server"
import { createOllama } from "ollama-ai-provider-v2"
import {
AIHUBMIX_APP_CODE,
isAihubmixStandardBaseURL,
normalizeMiniMaxBaseURL,
} from "@/lib/ai-providers"
import { normalizeMiniMaxBaseURL } from "@/lib/ai-providers"
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
@@ -56,7 +51,7 @@ export async function POST(req: Request) {
}
// SECURITY: Block SSRF attacks via custom baseUrl
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
if (baseUrl && !allowPrivateUrls() && isPrivateUrl(baseUrl)) {
return NextResponse.json(
{ valid: false, error: "Invalid base URL" },
{ status: 400 },
@@ -158,28 +153,6 @@ export async function POST(req: Request) {
break
}
case "aihubmix": {
const defaultBaseURL = PROVIDER_INFO.aihubmix.defaultBaseUrl
if (
isAihubmixStandardBaseURL(baseUrl) ||
baseUrl === defaultBaseURL
) {
const aihubmix = createAihubmix({
apiKey,
appCode: AIHUBMIX_APP_CODE,
})
model = aihubmix(modelId)
} else {
const aihubmixCompatible = createOpenAI({
apiKey,
baseURL: baseUrl,
})
model = aihubmixCompatible.chat(modelId)
}
break
}
case "deepseek": {
if (baseUrl || apiKey) {
const ds = createDeepSeek({

View File

@@ -54,7 +54,6 @@ import {
import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary"
import type { UseModelConfigReturn } from "@/hooks/use-model-config"
import { getApiEndpoint } from "@/lib/base-path"
import { formatMessage } from "@/lib/i18n/utils"
import type { ProviderConfig, ProviderName } from "@/lib/types/model-config"
import { PROVIDER_INFO, SUGGESTED_MODELS } from "@/lib/types/model-config"
@@ -133,14 +132,6 @@ export function ModelConfigDialog({
modelId: string
message: string
} | null>(null)
const [dynamicSuggestedModels, setDynamicSuggestedModels] = useState<
Partial<Record<ProviderName, string[]>>
>({})
const [loadedSuggestedProviders, setLoadedSuggestedProviders] = useState<
Partial<Record<ProviderName, boolean>>
>({})
const [loadingSuggestedProvider, setLoadingSuggestedProvider] =
useState<ProviderName | null>(null)
const {
config,
@@ -166,68 +157,10 @@ export function ModelConfigDialog({
}
}, [])
useEffect(() => {
if (
!open ||
selectedProvider?.provider !== "aihubmix" ||
loadedSuggestedProviders.aihubmix
) {
return
}
let cancelled = false
setLoadingSuggestedProvider("aihubmix")
fetch(getApiEndpoint("/api/aihubmix-models"))
.then((response) => {
if (!response.ok) {
throw new Error(`Failed to load models: ${response.status}`)
}
return response.json()
})
.then((data: { models?: unknown }) => {
if (cancelled || !Array.isArray(data.models)) {
return
}
const models = data.models.filter(
(model): model is string => typeof model === "string",
)
if (models.length > 0) {
setDynamicSuggestedModels((current) => ({
...current,
aihubmix: models,
}))
}
})
.catch((error) => {
console.warn("Failed to load AIHubMix models:", error)
})
.finally(() => {
if (cancelled) {
return
}
setLoadedSuggestedProviders((current) => ({
...current,
aihubmix: true,
}))
setLoadingSuggestedProvider(null)
})
return () => {
cancelled = true
}
}, [open, selectedProvider?.provider, loadedSuggestedProviders.aihubmix])
// Get suggested models for current provider
const suggestedModels = selectedProvider
? dynamicSuggestedModels[selectedProvider.provider] ||
SUGGESTED_MODELS[selectedProvider.provider] ||
[]
? SUGGESTED_MODELS[selectedProvider.provider] || []
: []
const isLoadingSuggestedModels =
selectedProvider?.provider === loadingSuggestedProvider
// Filter out already-added models from suggestions
const existingModelIds =
@@ -235,11 +168,6 @@ export function ModelConfigDialog({
const availableSuggestions = suggestedModels.filter(
(modelId) => !existingModelIds.includes(modelId),
)
const emptyStateSuggestions = selectedProvider
? (SUGGESTED_MODELS[selectedProvider.provider] || [])
.filter((modelId) => !existingModelIds.includes(modelId))
.slice(0, 4)
: []
// Handle adding a new provider
const handleAddProvider = (providerType: ProviderName) => {
@@ -845,26 +773,21 @@ export function ModelConfigDialog({
}
}}
disabled={
isLoadingSuggestedModels ||
availableSuggestions.length ===
0
0
}
>
<SelectTrigger className="w-28 h-8 rounded-lg hover:bg-interactive-hover">
{isLoadingSuggestedModels ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<span className="text-xs">
{availableSuggestions.length ===
0
? dict
.modelConfig
.allAdded
: dict
.modelConfig
.suggested}
</span>
)}
<span className="text-xs">
{availableSuggestions.length ===
0
? dict
.modelConfig
.allAdded
: dict
.modelConfig
.suggested}
</span>
</SelectTrigger>
<SelectContent className="max-h-72">
{availableSuggestions.map(
@@ -893,12 +816,7 @@ export function ModelConfigDialog({
0 ? (
<div className="p-6 text-center h-full flex flex-col items-center justify-center">
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-surface-2 mb-3">
<ProviderLogo
provider={
selectedProvider.provider
}
className="size-5 text-muted-foreground"
/>
<Sparkles className="h-5 w-5 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">
{
@@ -906,36 +824,6 @@ export function ModelConfigDialog({
.noModelsConfigured
}
</p>
{emptyStateSuggestions.length >
0 && (
<div className="mt-4 flex max-w-full flex-wrap items-center justify-center gap-2">
{emptyStateSuggestions.map(
(modelId) => (
<Button
key={
modelId
}
type="button"
variant="outline"
size="sm"
className="h-7 max-w-[220px] rounded-lg px-2 font-mono text-[11px]"
onClick={() =>
handleAddModel(
modelId,
)
}
>
<Plus className="h-3 w-3 shrink-0" />
<span className="truncate">
{
modelId
}
</span>
</Button>
),
)}
</div>
)}
</div>
) : (
<div className="divide-y divide-border-subtle">

View File

@@ -204,7 +204,6 @@ npm run dev
- Azure OpenAI
- Ollama
- OpenRouter
- AIHubMix
- DeepSeek
- SiliconFlow
- ModelScope
@@ -217,7 +216,7 @@ npm run dev
### 服务端多模型配置
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量JSON 字符串)或 `ai-models.json` 文件配置。如果只需要单 provider 下的多个模型,也可以直接在 `AI_MODEL` 中用逗号分隔模型 ID。
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量JSON 字符串)或 `ai-models.json` 文件配置。
**模型要求**此任务需要强大的模型能力因为它涉及生成具有严格格式约束的长文本draw.io XML。推荐使用 Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro 和 DeepSeek V3.2/R1。

View File

@@ -46,21 +46,6 @@ AI_MODEL=gpt-4o
OPENAI_BASE_URL=https://your-custom-endpoint/v1
```
### AIHubMix
AIHubMix 通过单个 API Key 聚合 Claude、GPT、Gemini、DeepSeek 等模型。
```bash
AIHUBMIX_API_KEY=your_api_key
AI_MODEL=claude-sonnet-4-5-20250929
```
可选的自定义端点:
```bash
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
```
### Anthropic
```bash
@@ -315,7 +300,7 @@ QINIU_BASE_URL=https://your-custom-endpoint
如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER`
```bash
AI_PROVIDER=google # 或openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu
AI_PROVIDER=google # 或openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu
```
## 服务端多模型配置
@@ -336,17 +321,6 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["
在项目根目录创建 `ai-models.json` 文件(或通过 `AI_MODELS_CONFIG_PATH` 指定路径)。
**方式三:`AI_MODEL` 用逗号分隔**(单 provider 的快速配置)
如果只需要暴露同一 provider 下的多个模型,可以直接在 `AI_MODEL` 里用逗号分隔。第一个模型会作为默认值。
```bash
AI_PROVIDER=doubao
AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
```
这是等价 `ai-models.json` 的简写形式。如果需要配置多个 provider或自定义 `apiKeyEnv` / `baseUrlEnv`,请使用方式一或方式二。
### 配置示例
```json

View File

@@ -61,21 +61,6 @@ Optional custom endpoint (for OpenAI-compatible services):
OPENAI_BASE_URL=https://your-custom-endpoint/v1
```
### AIHubMix
AIHubMix provides access to Claude, GPT, Gemini, DeepSeek, and other models through a single API key.
```bash
AIHUBMIX_API_KEY=your_api_key
AI_MODEL=claude-sonnet-4-5-20250929
```
Optional custom endpoint:
```bash
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
```
### Anthropic
```bash
@@ -330,7 +315,7 @@ If you only configure **one** provider's API key, the system will automatically
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
```bash
AI_PROVIDER=google # or: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu
```
## Server-Side Multi-Model Configuration
@@ -351,17 +336,6 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["
Create an `ai-models.json` file in the project root (or set `AI_MODELS_CONFIG_PATH` to a custom location).
**Option 3: Comma-separated `AI_MODEL`** (quick setup, single provider)
If you only need multiple models from one provider, list them in `AI_MODEL` separated by commas. The first model is treated as the default.
```bash
AI_PROVIDER=doubao
AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
```
This is shorthand for the equivalent `ai-models.json`. For multiple providers or custom `apiKeyEnv` / `baseUrlEnv`, use Option 1 or 2 instead.
### Example Configuration
```json

View File

@@ -203,7 +203,6 @@ Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成
- Azure OpenAI
- Ollama
- OpenRouter
- AIHubMix
- DeepSeek
- SiliconFlow
- ModelScope
@@ -216,7 +215,7 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
### サーバーサイドマルチモデル設定
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。`AI_MODELS_CONFIG` 環境変数JSON文字列または `ai-models.json` ファイルで設定します。同一プロバイダー内の複数モデルだけが必要な場合は、`AI_MODEL` にカンマ区切りでモデルIDを列挙する簡易設定も使えます。
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。`AI_MODELS_CONFIG` 環境変数JSON文字列または `ai-models.json` ファイルで設定します。
**モデル要件**このタスクは厳密なフォーマット制約draw.io XMLを持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro、DeepSeek V3.2/R1を推奨します。

View File

@@ -46,21 +46,6 @@ AI_MODEL=gpt-4o
OPENAI_BASE_URL=https://your-custom-endpoint/v1
```
### AIHubMix
AIHubMix は、単一の API キーで Claude、GPT、Gemini、DeepSeek などのモデルへのアクセスを提供します。
```bash
AIHUBMIX_API_KEY=your_api_key
AI_MODEL=claude-sonnet-4-5-20250929
```
任意のカスタムエンドポイント:
```bash
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
```
### Anthropic
```bash
@@ -315,7 +300,7 @@ QINIU_BASE_URL=https://your-custom-endpoint
**複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります:
```bash
AI_PROVIDER=google # または: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu
AI_PROVIDER=google # または: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu
```
## サーバーサイドマルチモデル設定
@@ -336,17 +321,6 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["
プロジェクトルートに `ai-models.json` ファイルを作成します(または `AI_MODELS_CONFIG_PATH` でパスを指定)。
**方法3`AI_MODEL` をカンマ区切りで指定**(単一プロバイダーの簡易設定)
同一プロバイダー内の複数モデルだけを公開したい場合は、`AI_MODEL` にカンマ区切りで列挙できます。最初のモデルがデフォルトになります。
```bash
AI_PROVIDER=doubao
AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
```
これは等価な `ai-models.json` の簡易表記です。複数のプロバイダーや、カスタム `apiKeyEnv` / `baseUrlEnv` を使う場合は、方法1または方法2を使ってください。
### 設定例
```json

View File

@@ -1,14 +1,10 @@
# 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, deepseek, siliconflow, gateway, novita
# Default: bedrock
AI_PROVIDER=bedrock
# AI_MODEL: The model ID for your chosen provider (REQUIRED)
# Tip: For a single-provider quick multi-model setup, list comma-separated model IDs.
# The first one becomes the default and the rest appear in the model picker.
# For multiple providers or custom apiKeyEnv/baseUrlEnv, use AI_MODELS_CONFIG / ai-models.json instead.
# Example: AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# AWS Bedrock Configuration
@@ -73,10 +69,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# OPENROUTER_API_KEY=sk-or-v1-...
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 # Optional: Custom endpoint
# AIHubMix Configuration
# AIHUBMIX_API_KEY=your-aihubmix-api-key
# AIHUBMIX_BASE_URL=https://aihubmix.com/v1 # Optional: Custom endpoint
# DeepSeek Configuration
# DEEPSEEK_API_KEY=sk-...
# DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 # Optional: Custom endpoint

View File

@@ -6,7 +6,6 @@ import { createGateway, gateway } from "@ai-sdk/gateway"
import { createGoogleGenerativeAI, google } from "@ai-sdk/google"
import { createVertex } from "@ai-sdk/google-vertex"
import { createOpenAI, openai } from "@ai-sdk/openai"
import { aihubmix, createAihubmix } from "@aihubmix/ai-sdk-provider"
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { createOllama, ollama } from "ollama-ai-provider-v2"
@@ -14,8 +13,6 @@ import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
export type { ProviderName }
export const AIHUBMIX_APP_CODE = "MSBS9675"
interface ModelConfig {
model: any
providerOptions?: any
@@ -60,18 +57,6 @@ export function normalizeMiniMaxBaseURL(rawUrl: string): {
return { baseURL, isAnthropicCompatible }
}
export function isAihubmixStandardBaseURL(
rawUrl: string | null | undefined,
): boolean {
if (!rawUrl) return true
const baseURL = rawUrl.replace(/\/+$/, "")
return (
baseURL === "https://aihubmix.com" ||
baseURL === "https://aihubmix.com/v1"
)
}
export interface ClientOverrides {
provider?: string | null
baseUrl?: string | null
@@ -101,7 +86,6 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
"azure",
"bedrock",
"openrouter",
"aihubmix",
"deepseek",
"siliconflow",
"sglang",
@@ -529,7 +513,6 @@ function buildProviderOptions(
case "deepseek":
case "openrouter":
case "aihubmix":
case "siliconflow":
case "sglang":
case "gateway":
@@ -563,7 +546,6 @@ export const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
azure: "AZURE_API_KEY",
ollama: null, // No credentials needed for local Ollama
openrouter: "OPENROUTER_API_KEY",
aihubmix: "AIHUBMIX_API_KEY",
deepseek: "DEEPSEEK_API_KEY",
siliconflow: "SILICONFLOW_API_KEY",
sglang: "SGLANG_API_KEY",
@@ -680,7 +662,7 @@ function validateProviderCredentials(
* Get the AI model based on environment variables
*
* Environment variables:
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, sglang, gateway, modelscope)
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, modelscope)
* - AI_MODEL: The model ID/name for the selected provider
*
* Provider-specific env vars:
@@ -692,7 +674,6 @@ function validateProviderCredentials(
* - AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY: AWS Bedrock credentials
* - OLLAMA_BASE_URL: Ollama server URL (optional, defaults to https://ollama.com/api)
* - OPENROUTER_API_KEY: OpenRouter API key
* - AIHUBMIX_API_KEY: AIHubMix API key
* - DEEPSEEK_API_KEY: DeepSeek API key
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
* - SILICONFLOW_API_KEY: SiliconFlow API key
@@ -729,10 +710,8 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
(overrides?.provider === "vertexai" && overrides?.vertexApiKey))
)
// Use client override if provided, otherwise fall back to env vars.
// AI_MODEL may be comma-separated (multi-model fallback); pick the first.
const envModel = process.env.AI_MODEL?.split(",")[0]?.trim() || undefined
const modelId = overrides?.modelId || envModel
// Use client override if provided, otherwise fall back to env vars
const modelId = overrides?.modelId || process.env.AI_MODEL
if (!modelId) {
if (isClientOverride) {
@@ -782,7 +761,6 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
`- GOOGLE_GENERATIVE_AI_API_KEY for Google\n` +
`- AWS_ACCESS_KEY_ID for Bedrock\n` +
`- OPENROUTER_API_KEY for OpenRouter\n` +
`- AIHUBMIX_API_KEY for AIHubMix\n` +
`- AZURE_API_KEY for Azure\n` +
`- SILICONFLOW_API_KEY for SiliconFlow\n` +
`- SGLANG_API_KEY for SGLang\n` +
@@ -1025,42 +1003,6 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
break
}
case "aihubmix": {
const apiKey = resolveApiKey(overrides, "AIHUBMIX_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"AIHUBMIX_BASE_URL",
)
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
serverBaseUrl,
PROVIDER_INFO.aihubmix.defaultBaseUrl,
)
const defaultBaseURL = PROVIDER_INFO.aihubmix.defaultBaseUrl
if (
isAihubmixStandardBaseURL(baseURL) ||
baseURL === defaultBaseURL
) {
const aihubmixProvider =
overrides?.apiKey || apiKey
? createAihubmix({
apiKey,
appCode: AIHUBMIX_APP_CODE,
})
: aihubmix
model = aihubmixProvider(modelId)
} else {
const aihubmixCompatibleProvider = createOpenAI({
apiKey,
baseURL,
})
model = aihubmixCompatibleProvider.chat(modelId)
}
break
}
case "deepseek": {
const apiKey = resolveApiKey(overrides, "DEEPSEEK_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
@@ -1380,7 +1322,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
overrides?.apiKey,
overrides?.baseUrl,
resolveBaseUrlEnv(overrides, "KIMI_BASE_URL"),
PROVIDER_INFO.kimi?.defaultBaseUrl,
PROVIDER_INFO["kimi"]?.defaultBaseUrl,
)
// Use createDeepSeek to properly handle reasoning_content for Kimi
// thinking models (e.g., kimi-k2.6). Kimi's API uses the same
@@ -1393,7 +1335,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
default:
throw new Error(
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax, novita`,
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax, novita`,
)
}
@@ -1492,9 +1434,7 @@ export function supportsImageInput(modelId: string): boolean {
* Throws if the model doesn't support image input.
*/
export function getValidationModel(): ReturnType<typeof getAIModel>["model"] {
// AI_MODEL may be comma-separated (multi-model fallback); pick the first.
const envFallback = process.env.AI_MODEL?.split(",")[0]?.trim() || undefined
const modelId = process.env.VALIDATION_MODEL || envFallback
const modelId = process.env.VALIDATION_MODEL || process.env.AI_MODEL
if (!modelId) {
throw new Error(

View File

@@ -1,79 +0,0 @@
export const AIHUBMIX_MODELS_ENDPOINT = "https://aihubmix.com/api/v1/models"
const NON_CHAT_MODEL_TYPES = new Set([
"embedding",
"image_generation",
"rerank",
"transcription",
"tts",
"video",
])
type AihubmixModelListPayload = {
data?: unknown
}
type AihubmixModelRecord = {
model_id?: unknown
types?: unknown
}
function getModelTypes(types: unknown): Set<string> {
if (typeof types !== "string") {
return new Set()
}
return new Set(
types
.split(",")
.map((type) => type.trim())
.filter(Boolean),
)
}
function isChatModel(record: AihubmixModelRecord): record is {
model_id: string
types: string
} {
if (typeof record.model_id !== "string" || !record.model_id.trim()) {
return false
}
const types = getModelTypes(record.types)
if (!types.has("llm")) {
return false
}
return !Array.from(NON_CHAT_MODEL_TYPES).some((type) => types.has(type))
}
export function extractAihubmixModelIds(payload: unknown): string[] {
const data = (payload as AihubmixModelListPayload)?.data
if (!Array.isArray(data)) {
return []
}
const seen = new Set<string>()
const modelIds: string[] = []
for (const item of data) {
if (!item || typeof item !== "object") {
continue
}
const record = item as AihubmixModelRecord
if (!isChatModel(record)) {
continue
}
const modelId = record.model_id.trim()
if (seen.has(modelId)) {
continue
}
seen.add(modelId)
modelIds.push(modelId)
}
return modelIds
}

View File

@@ -62,53 +62,6 @@ function getConfigPath(): string {
return path.join(process.cwd(), "ai-models.json")
}
/**
* Synthesize a config from a comma-separated AI_MODEL value (Priority 3 fallback).
* Lets users expose multiple models without authoring AI_MODELS_CONFIG / ai-models.json.
* Triggers only when AI_MODEL contains a comma AND AI_PROVIDER is set to a known provider.
*/
function configFromCommaSeparatedAiModel(): ServerModelsConfig | null {
const aiModel = process.env.AI_MODEL
if (!aiModel || !aiModel.includes(",")) return null
const aiProvider = process.env.AI_PROVIDER
if (!aiProvider) {
console.warn(
"[server-model-config] AI_MODEL contains commas but AI_PROVIDER is not set; " +
"skipping multi-model fallback. Set AI_PROVIDER, or use AI_MODELS_CONFIG / ai-models.json.",
)
return null
}
if (!(aiProvider in PROVIDER_INFO)) {
console.warn(
`[server-model-config] AI_PROVIDER="${aiProvider}" is not a known provider; skipping multi-model fallback.`,
)
return null
}
const models = Array.from(
new Set(
aiModel
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0),
),
)
if (models.length === 0) return null
const providerName = aiProvider as ProviderName
return {
providers: [
{
name: PROVIDER_INFO[providerName]?.label || providerName,
provider: providerName,
models,
default: true,
},
],
}
}
export async function loadEnvServerModelsConfig(): Promise<ServerModelsConfig | null> {
// Priority 1: AI_MODELS_CONFIG env var (JSON string) - for cloud deployments
const envConfig = process.env.AI_MODELS_CONFIG
@@ -132,17 +85,15 @@ export async function loadEnvServerModelsConfig(): Promise<ServerModelsConfig |
const json = JSON.parse(jsonStr)
return ServerModelsConfigSchema.parse(json)
} catch (err: any) {
if (err?.code !== "ENOENT") {
console.error(
"[server-model-config] Failed to load ai-models.json:",
err,
)
if (err?.code === "ENOENT") {
return null
}
console.error(
"[server-model-config] Failed to load ai-models.json:",
err,
)
return null
}
// Priority 3: AI_MODEL with comma-separated values + AI_PROVIDER
return configFromCommaSeparatedAiModel()
}
export async function loadRawServerModelsConfig(): Promise<ServerModelsConfig | null> {

View File

@@ -2,108 +2,80 @@
* SSRF (Server-Side Request Forgery) protection utilities
*/
import { lookup } from "node:dns/promises"
/**
* Check if an IP address (IPv4 or IPv6) belongs to a private/internal range.
* Works for both user-supplied literal IPs and DNS-resolved addresses.
* Check if URL points to private/internal network
* Blocks: localhost, private IPs, link-local, AWS metadata service
*/
function isPrivateIp(ip: string): boolean {
const addr = ip.toLowerCase().replace(/^\[|\]$/g, "")
export function isPrivateUrl(urlString: string): boolean {
try {
const url = new URL(urlString)
// Strip a trailing dot so FQDN forms like "localhost." (which still
// resolve to 127.0.0.1) cannot bypass the equality checks below.
const hostname = url.hostname
.toLowerCase()
.replace(/^\[|\]$/g, "")
.replace(/\.$/, "")
// IPv6
if (addr.includes(":")) {
if (addr === "::1" || addr === "::") return true
// unique-local (fc00::/7) and IPv4-mapped (::ffff:0:0/96)
// Block localhost
if (
addr.startsWith("fc") ||
addr.startsWith("fd") ||
addr.startsWith("::ffff:")
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1" ||
hostname === "::"
) {
return true
}
// link-local (fe80::/10)
const linkLocal = addr.match(/^fe([0-9a-f]{2}):/)
if (linkLocal) {
const high = parseInt(linkLocal[1], 16)
if (high >= 0x80 && high <= 0xbf) return true
// Block IPv6 unique-local (fc00::/7), link-local (fe80::/10),
// and IPv4-mapped (::ffff:0:0/96) hosts.
if (hostname.includes(":")) {
if (
hostname.startsWith("fc") ||
hostname.startsWith("fd") ||
hostname.startsWith("::ffff:")
) {
return true
}
const linkLocal = hostname.match(/^fe([0-9a-f]{2}):/)
if (linkLocal) {
const high = parseInt(linkLocal[1], 16)
if (high >= 0x80 && high <= 0xbf) return true
}
}
// Block AWS/cloud metadata endpoints
if (
hostname === "169.254.169.254" ||
hostname === "metadata.google.internal"
) {
return true
}
// Check for private IPv4 ranges
const ipv4Match = hostname.match(
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
)
if (ipv4Match) {
const [, a, b] = ipv4Match.map(Number)
if (a === 10) return true // 10.0.0.0/8
if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12
if (a === 192 && b === 168) return true // 192.168.0.0/16
if (a === 169 && b === 254) return true // 169.254.0.0/16 (link-local)
if (a === 127) return true // 127.0.0.0/8 (loopback)
}
// Block common internal hostnames
if (
hostname.endsWith(".local") ||
hostname.endsWith(".internal") ||
hostname.endsWith(".localhost")
) {
return true
}
return false
}
// IPv4
const ipv4Match = addr.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
if (ipv4Match) {
const [, a, b] = ipv4Match.map(Number)
if (a === 10) return true // 10.0.0.0/8
if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12
if (a === 192 && b === 168) return true // 192.168.0.0/16
if (a === 169 && b === 254) return true // 169.254.0.0/16 (link-local)
if (a === 127) return true // 127.0.0.0/8 (loopback)
if (a === 0) return true // 0.0.0.0/8
if (a === 100 && b >= 64 && b <= 127) return true // 100.64.0.0/10 (CGNAT, used by some cloud internal networks)
}
return false
}
/**
* String-only check against well-known private hostnames and literal IPs.
* Fast path that avoids a DNS lookup for obvious cases.
*/
function isPrivateHostname(hostname: string): boolean {
const host = hostname
.toLowerCase()
.replace(/^\[|\]$/g, "")
.replace(/\.$/, "")
if (
host === "localhost" ||
host === "127.0.0.1" ||
host === "::1" ||
host === "::"
) {
return true
}
if (host === "169.254.169.254" || host === "metadata.google.internal") {
return true
}
if (
host.endsWith(".local") ||
host.endsWith(".internal") ||
host.endsWith(".localhost")
) {
return true
}
// Literal IP supplied directly in the URL
return isPrivateIp(host)
}
/**
* Check if URL points to private/internal network.
* Blocks: localhost, private IPs, link-local, AWS metadata service.
*
* Resolves the hostname via DNS and validates every returned address, so
* public-looking names that map to internal IPs (e.g. "127-0-0-1.sslip.io")
* are caught even though they pass the string-only check.
*/
export async function isPrivateUrl(urlString: string): Promise<boolean> {
try {
const url = new URL(urlString)
const hostname = url.hostname
// Fast path: obvious string matches and literal IPs.
if (isPrivateHostname(hostname)) return true
// Resolve DNS and reject if any address is private.
const stripped = hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "")
const addresses = await lookup(stripped, { all: true })
return addresses.some(({ address }) => isPrivateIp(address))
} catch {
return true // Invalid URL or DNS failure - block it
return true // Invalid URL - block it
}
}

View File

@@ -9,7 +9,6 @@ export type ProviderName =
| "bedrock"
| "ollama"
| "openrouter"
| "aihubmix"
| "deepseek"
| "siliconflow"
| "sglang"
@@ -103,7 +102,6 @@ export const PROVIDER_LOGO_MAP: Record<string, string> = {
azure: "azure",
bedrock: "amazon-bedrock",
openrouter: "openrouter",
aihubmix: "aihubmix",
deepseek: "deepseek",
siliconflow: "siliconflow",
sglang: "openai", // SGLang is OpenAI-compatible
@@ -147,10 +145,6 @@ export const PROVIDER_INFO: Record<
label: "OpenRouter",
defaultBaseUrl: "https://openrouter.ai/api/v1",
},
aihubmix: {
label: "AIHubMix",
defaultBaseUrl: "https://aihubmix.com/v1",
},
deepseek: {
label: "DeepSeek",
defaultBaseUrl: "https://api.deepseek.com/v1",
@@ -323,41 +317,6 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
// MiniMax
"minimax/minimax-m3",
],
aihubmix: [
// Fallback list. The settings UI loads the live model list from AIHubMix when available.
// Anthropic Claude
"claude-fable-5",
"claude-opus-4-8",
"claude-sonnet-4-6",
// OpenAI
"gpt-5.5",
"gpt-5.5-pro",
"gpt-5.4",
// Google Gemini
"gemini-3.5-flash",
"gemini-3.1-pro-preview",
"gemini-3-flash-preview",
// DeepSeek
"deepseek-v4-pro",
"deepseek-v4-flash",
// Qwen
"qwen3.7-max",
"qwen3-coder-next",
// Z.ai
"glm-5.1",
// Moonshot AI
"kimi-k2.6",
// MiniMax
"minimax-m3",
// xAI
"grok-4.3",
// Baidu
"ernie-5.1",
// Mistral
"mistral-large-3",
// Meta
"llama-4-maverick",
],
deepseek: [
"deepseek-v4-pro",
"deepseek-v4-flash",

280
package-lock.json generated
View File

@@ -18,7 +18,6 @@
"@ai-sdk/google-vertex": "^4.0.16",
"@ai-sdk/openai": "^3.0.0",
"@ai-sdk/react": "^3.0.1",
"@aihubmix/ai-sdk-provider": "^2.1.0",
"@aws-sdk/client-dynamodb": "^3.957.0",
"@aws-sdk/credential-providers": "^3.943.0",
"@extractus/article-extractor": "^8.0.18",
@@ -321,51 +320,6 @@
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/openai-compatible": {
"version": "2.0.48",
"resolved": "https://registry.npmjs.org/@ai-sdk/openai-compatible/-/openai-compatible-2.0.48.tgz",
"integrity": "sha512-z9MC6M4Oh/yUY/F/eszOtO8wc2nMz99XmZQKd2gWTtyIfe716xTfrKe3aYZKg20NZDtyjqPPKPSR+wqz7q1T7Q==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.10",
"@ai-sdk/provider-utils": "4.0.27"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/openai-compatible/node_modules/@ai-sdk/provider": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.10.tgz",
"integrity": "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==",
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@ai-sdk/openai-compatible/node_modules/@ai-sdk/provider-utils": {
"version": "4.0.27",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.27.tgz",
"integrity": "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.10",
"@standard-schema/spec": "^1.1.0",
"eventsource-parser": "^3.0.8"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/provider": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz",
@@ -413,26 +367,6 @@
"react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1"
}
},
"node_modules/@aihubmix/ai-sdk-provider": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@aihubmix/ai-sdk-provider/-/ai-sdk-provider-2.1.0.tgz",
"integrity": "sha512-AqK10PV5B4zWFBav5PRUhrWGYTHjC0s6cIbd4v9hBo6D9SqKv5o41B+dkl6IeIPrnZrMGLEE9Mn5R+VaEkhbdg==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/anthropic": "^3.0.0",
"@ai-sdk/google": "^3.0.0",
"@ai-sdk/openai": "^3.0.0",
"@ai-sdk/openai-compatible": "^2.0.37",
"@ai-sdk/provider": "^3.0.0",
"@ai-sdk/provider-utils": "^4.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -3408,9 +3342,9 @@
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"cpu": [
"ppc64"
],
@@ -3425,9 +3359,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
"cpu": [
"arm"
],
@@ -3442,9 +3376,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
"cpu": [
"arm64"
],
@@ -3459,9 +3393,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
"cpu": [
"x64"
],
@@ -3476,9 +3410,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
"cpu": [
"arm64"
],
@@ -3493,9 +3427,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
"cpu": [
"x64"
],
@@ -3510,9 +3444,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
"cpu": [
"arm64"
],
@@ -3527,9 +3461,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
"cpu": [
"x64"
],
@@ -3544,9 +3478,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
"cpu": [
"arm"
],
@@ -3561,9 +3495,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
"cpu": [
"arm64"
],
@@ -3578,9 +3512,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
"cpu": [
"ia32"
],
@@ -3595,9 +3529,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
"cpu": [
"loong64"
],
@@ -3612,9 +3546,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
"cpu": [
"mips64el"
],
@@ -3629,9 +3563,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
"cpu": [
"ppc64"
],
@@ -3646,9 +3580,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
"cpu": [
"riscv64"
],
@@ -3663,9 +3597,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
"cpu": [
"s390x"
],
@@ -3680,9 +3614,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
"cpu": [
"x64"
],
@@ -3697,9 +3631,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
"cpu": [
"arm64"
],
@@ -3714,9 +3648,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
"cpu": [
"x64"
],
@@ -3731,9 +3665,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
"cpu": [
"arm64"
],
@@ -3748,9 +3682,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
"cpu": [
"x64"
],
@@ -3765,9 +3699,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
"cpu": [
"arm64"
],
@@ -3782,9 +3716,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
"cpu": [
"x64"
],
@@ -3799,9 +3733,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
"cpu": [
"arm64"
],
@@ -3816,9 +3750,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
"cpu": [
"ia32"
],
@@ -3833,9 +3767,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
"cpu": [
"x64"
],
@@ -13300,9 +13234,9 @@
"optional": true
},
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -13313,32 +13247,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
"@esbuild/aix-ppc64": "0.28.0",
"@esbuild/android-arm": "0.28.0",
"@esbuild/android-arm64": "0.28.0",
"@esbuild/android-x64": "0.28.0",
"@esbuild/darwin-arm64": "0.28.0",
"@esbuild/darwin-x64": "0.28.0",
"@esbuild/freebsd-arm64": "0.28.0",
"@esbuild/freebsd-x64": "0.28.0",
"@esbuild/linux-arm": "0.28.0",
"@esbuild/linux-arm64": "0.28.0",
"@esbuild/linux-ia32": "0.28.0",
"@esbuild/linux-loong64": "0.28.0",
"@esbuild/linux-mips64el": "0.28.0",
"@esbuild/linux-ppc64": "0.28.0",
"@esbuild/linux-riscv64": "0.28.0",
"@esbuild/linux-s390x": "0.28.0",
"@esbuild/linux-x64": "0.28.0",
"@esbuild/netbsd-arm64": "0.28.0",
"@esbuild/netbsd-x64": "0.28.0",
"@esbuild/openbsd-arm64": "0.28.0",
"@esbuild/openbsd-x64": "0.28.0",
"@esbuild/openharmony-arm64": "0.28.0",
"@esbuild/sunos-x64": "0.28.0",
"@esbuild/win32-arm64": "0.28.0",
"@esbuild/win32-ia32": "0.28.0",
"@esbuild/win32-x64": "0.28.0"
}
},
"node_modules/escalade": {

View File

@@ -40,7 +40,6 @@
"@ai-sdk/google-vertex": "^4.0.16",
"@ai-sdk/openai": "^3.0.0",
"@ai-sdk/react": "^3.0.1",
"@aihubmix/ai-sdk-provider": "^2.1.0",
"@aws-sdk/client-dynamodb": "^3.957.0",
"@aws-sdk/credential-providers": "^3.943.0",
"@extractus/article-extractor": "^8.0.18",

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@next-ai-drawio/mcp-server",
"version": "0.2.1",
"version": "0.2.0",
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
"type": "module",
"main": "dist/index.js",
@@ -11,8 +11,6 @@
"build": "tsc",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"test": "vitest run",
"test:watch": "vitest",
"prepublishOnly": "npm run build"
},
"keywords": [
@@ -46,8 +44,7 @@
"devDependencies": {
"@types/node": "^24.0.0",
"tsx": "^4.19.0",
"typescript": "^5",
"vitest": "^4.1.8"
"typescript": "^5"
},
"engines": {
"node": ">=18"

View File

@@ -1,14 +1,8 @@
/**
* ID-based diagram operations
*
* The xmlContent argument may be either a bare <mxGraphModel> (legacy) or a
* full <mxfile> with one or more <diagram> pages. For mxfile inputs, an
* optional pageSelector identifies which page to edit; when omitted, the
* first page is targeted (the "active page by convention" — see pages.ts).
* Copied from lib/utils.ts to avoid cross-package imports
*/
import { findPageElement, hasPageSelector, type PageSelector } from "./pages.js"
export interface DiagramOperation {
operation: "update" | "add" | "delete"
cell_id: string
@@ -28,18 +22,15 @@ export interface ApplyOperationsResult {
/**
* Apply diagram operations (update/add/delete) using ID-based lookup.
* This replaces the text-matching approach with direct DOM manipulation.
*
* @param xmlContent - The diagram XML. May be either a bare <mxGraphModel> or
* a full <mxfile> with one or more <diagram> children.
* @param operations - Array of operations to apply.
* @param pageSelector - Optional page selector for multi-page docs. Defaults
* to the first page.
* @returns Object with result XML (same shape as input) and any per-op errors.
* @param xmlContent - The full mxfile XML content
* @param operations - Array of operations to apply
* @returns Object with result XML and any errors
*/
export function applyDiagramOperations(
xmlContent: string,
operations: DiagramOperation[],
pageSelector?: PageSelector,
): ApplyOperationsResult {
const errors: OperationError[] = []
@@ -62,75 +53,22 @@ export function applyDiagramOperations(
}
}
// Locate the <root> element to operate on.
//
// - For <mxfile> input: resolve the page via pageSelector, then dive into
// its <root>. This scopes querySelectorAll calls below to one page so
// cells on other pages aren't accidentally matched.
// - For bare <mxGraphModel> input: use the document's only <root>.
let root: Element | null
if (doc.documentElement?.tagName === "mxfile") {
const found = findPageElement(doc as unknown as Document, pageSelector)
if (!found) {
const selDesc = hasPageSelector(pageSelector)
? ` matching selector ${JSON.stringify(pageSelector)}`
: ""
return {
result: xmlContent,
errors: [
{
type: "update",
cellId: "",
message: `Page${selDesc} not found in <mxfile>`,
},
],
}
}
root = found.element.querySelector("root")
if (!root) {
const pageId =
found.element.getAttribute("id") || `(index ${found.index})`
return {
result: xmlContent,
errors: [
{
type: "update",
cellId: "",
message: `Page "${pageId}" has no <root> element`,
},
],
}
}
} else {
if (hasPageSelector(pageSelector)) {
return {
result: xmlContent,
errors: [
{
type: "update",
cellId: "",
message:
"Page selector provided but document is not multi-page (no <mxfile> wrapper). Use create_new_diagram with a full <mxfile> first, or omit the page selector.",
},
],
}
}
root = doc.querySelector("root")
if (!root) {
return {
result: xmlContent,
errors: [
{
type: "update",
cellId: "",
message: "Could not find <root> element in XML",
},
],
}
// Find the root element (inside mxGraphModel)
const root = doc.querySelector("root")
if (!root) {
return {
result: xmlContent,
errors: [
{
type: "update",
cellId: "",
message: "Could not find <root> element in XML",
},
],
}
}
// Build a map of cell IDs to elements (scoped to the resolved page).
// Build a map of cell IDs to elements
const cellMap = new Map<string, Element>()
root.querySelectorAll("mxCell").forEach((cell) => {
const id = cell.getAttribute("id")
@@ -270,9 +208,7 @@ export function applyDiagramOperations(
cellsToDelete.add(cellId)
// Find children (cells where parent === cellId)
// Scoped to `root` so other pages' cells with the same parent id
// (notably "1") are never touched.
const children = root!.querySelectorAll(
const children = root.querySelectorAll(
`mxCell[parent="${cellId}"]`,
)
children.forEach((child) => {

View File

@@ -93,7 +93,6 @@ interface SessionState {
svg?: string // Cached SVG from last browser save
syncRequested?: number // Timestamp when sync requested, cleared when browser responds
exportFormat?: "png" | "svg" // Set by MCP tool to request browser export
exportXml?: string // Single-page projection to load before a page-targeted export
exportData?: string // Base64/SVG data returned by browser after export
}
@@ -118,37 +117,12 @@ export function setState(sessionId: string, xml: string, svg?: string): number {
svg: svg || existing?.svg, // Preserve cached SVG if not provided
syncRequested: undefined, // Clear sync request when browser pushes state
exportFormat: existing?.exportFormat, // Preserve pending export request
exportXml: existing?.exportXml, // Preserve pending projection
exportData: existing?.exportData, // Preserve export result
})
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
return newVersion
}
/**
* Ask the browser bridge to export the current diagram as png/svg.
*
* When `projectionXml` is given (a single-page <mxfile>), the bridge loads it
* first, waits for draw.io's own load event, exports, then reloads the
* session's real document — so a page-targeted export never mutates the
* canonical session state and needs no fixed-delay guessing on the server.
*
* Returns false when the session is unknown. Callers should then poll
* `getState(sessionId)?.exportData` for the result.
*/
export function requestExport(
sessionId: string,
format: "png" | "svg",
projectionXml?: string,
): boolean {
const state = stateStore.get(sessionId)
if (!state) return false
state.exportData = undefined
state.exportXml = projectionXml
state.exportFormat = format
return true
}
export function requestSync(sessionId: string): boolean {
const state = stateStore.get(sessionId)
if (state) {
@@ -312,7 +286,6 @@ function handleStateApi(
version: state?.version || 0,
syncRequested: !!state?.syncRequested,
exportFormat: state?.exportFormat || null,
exportXml: state?.exportXml || null,
}),
)
} else if (req.method === "POST") {
@@ -332,7 +305,6 @@ function handleStateApi(
if (state) {
state.exportData = data.exportData
state.exportFormat = undefined
state.exportXml = undefined
log.debug(
`Export data received for session=${sessionId}`,
)
@@ -703,8 +675,6 @@ function getHtmlPage(sessionId: string): string {
let pendingSvgExport = null;
let pendingAiSvg = false;
let pendingMcpExport = null; // 'png' or 'svg' when MCP requested export
let projectionExportActive = false; // page-targeted export: showing a transient single-page projection
let projectionRestoreXml = null; // the real document to reload once a projection export finishes
window.addEventListener('message', (e) => {
if (e.origin !== '${DRAWIO_ORIGIN}') return;
@@ -714,10 +684,6 @@ function getHtmlPage(sessionId: string): string {
isReady = true;
if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; }
} else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) {
// Ignore autosave while a single-page projection is on screen
// for a page-targeted export — otherwise we'd push the
// transient projection back as the canonical session state.
if (projectionExportActive) return;
// Request SVG export, then push state with SVG
pendingSvgExport = msg.xml;
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
@@ -738,9 +704,6 @@ function getHtmlPage(sessionId: string): string {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, exportData: d })
}).catch(() => {});
// Page-targeted export: restore the user's real
// multi-page document now that we have the image.
restoreFromProjection();
return;
}
}
@@ -798,22 +761,6 @@ function getHtmlPage(sessionId: string): string {
}
}
// Restore the user's real document after a page-targeted projection
// export. If we never captured one (lastXml was null at projection
// start), fall back to forcing a reload from the server on the next
// poll by rewinding currentVersion — never leave the iframe stuck on
// the transient projection.
function restoreFromProjection() {
if (!projectionExportActive) return;
projectionExportActive = false;
if (projectionRestoreXml) {
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: projectionRestoreXml, autosave: 1 }), '*');
projectionRestoreXml = null;
} else {
currentVersion = -1; // force the next poll to reload from server
}
}
async function pushState(xml, svg = '') {
if (!sessionId) return;
try {
@@ -839,54 +786,20 @@ function getHtmlPage(sessionId: string): string {
pendingSyncExport = true;
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml' }), '*');
}
// Load new diagram from server (before export, so we export latest).
// While a page-targeted projection is on screen, skip the reload
// so it doesn't fight the projection — and leave currentVersion
// unadvanced so this bump is re-detected and applied once the
// real document is restored.
if (s.version > currentVersion && s.xml && !projectionExportActive) {
// Load new diagram from server (before export, so we export latest)
if (s.version > currentVersion && s.xml) {
currentVersion = s.version;
loadDiagram(s.xml, true);
}
// Handle export request from MCP server (png/svg).
//
// Plain export: capture whatever tab is currently displayed.
//
// Page-targeted export: the server sends a single-page <mxfile>
// projection in s.exportXml. We load it into the iframe, let
// draw.io render it, export, then reload the user's real
// document — all browser-side. The canonical session state is
// never mutated, so there is no server-side restore race and no
// dependence on poll timing. autosave is suppressed while the
// projection is showing (see projectionExportActive guard).
// Handle export request from MCP server (png/svg) - after version update
if (s.exportFormat && !pendingMcpExport && isReady) {
pendingMcpExport = s.exportFormat;
const fireExport = () => {
const exportOpts = pendingMcpExport === 'png'
? { action: 'export', format: 'png', scale: 2 }
: { action: 'export', format: 'svg' };
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
};
if (s.exportXml) {
// Stash the real document so we can restore after export.
projectionRestoreXml = lastXml;
projectionExportActive = true;
// Load the projection without touching lastXml/server state.
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: s.exportXml, autosave: 0 }), '*');
// Let draw.io render the loaded page before exporting
// (same proven settle delay as the AI-preview path).
setTimeout(fireExport, 600);
} else {
fireExport();
}
// Timeout: reset if draw.io never responds, and restore the
// real document if a projection was left showing.
setTimeout(() => {
if (pendingMcpExport) {
pendingMcpExport = null;
restoreFromProjection();
}
}, 10000);
const exportOpts = s.exportFormat === 'png'
? { action: 'export', format: 'png', scale: 2 }
: { action: 'export', format: 'svg' };
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
// Timeout: reset if draw.io never responds
setTimeout(() => { if (pendingMcpExport) { pendingMcpExport = null; } }, 8000);
}
} catch {}
}
@@ -926,11 +839,7 @@ function getHtmlPage(sessionId: string): string {
saveConfirmBtn.textContent = 'Exporting...';
if (format === 'drawio') {
// Use lastXml directly instead of requesting export (avoids race with SVG exports).
// session.xml is canonically <mxfile> after the multi-page refactor,
// so no wrapper injection is needed. The legacy fallback below
// remains only for documents that somehow slipped past
// normalisation (e.g. an older session loaded from external state).
// Use lastXml directly instead of requesting export (avoids race with SVG exports)
let xmlData = lastXml || '';
if (xmlData && !xmlData.includes('<mxfile')) {
xmlData = '<mxfile host="mcp"><diagram name="Page-1">' + xmlData + '</diagram></mxfile>';

File diff suppressed because it is too large Load Diff

View File

@@ -1,316 +0,0 @@
/**
* Multi-page (mxfile) helpers for draw.io diagrams.
*
* The on-disk and embed-protocol shape of a draw.io document is:
*
* <mxfile host="...">
* <diagram id="..." name="...">
* <mxGraphModel><root><mxCell .../>...</root></mxGraphModel>
* </diagram>
* ...one or more <diagram> children...
* </mxfile>
*
* This module centralises page CRUD so that index.ts, xml-validation.ts,
* and diagram-operations.ts can all agree on:
* - what "the canonical in-memory shape" is (always mxfile),
* - how to find a page (id, name, or index),
* - how to add/rename/delete pages without re-parsing ad-hoc.
*/
import { DOMParser } from "linkedom"
export interface PageInfo {
id: string
name: string
index: number
cellCount: number
}
/** Selector used by all multi-page-aware tools. All fields optional. */
export interface PageSelector {
page_id?: string
page_name?: string
page_index?: number
}
/** True if the selector targets a specific page (any field set). */
export function hasPageSelector(s?: PageSelector | null): boolean {
if (!s) return false
return (
Boolean(s.page_id) || Boolean(s.page_name) || s.page_index !== undefined
)
}
/**
* Generate a short page id similar in shape to drawio's auto-assigned ids.
* Format: 12 chars alphanumeric with a single dash. Not a UUID — drawio itself
* uses short ids; collisions are still astronomically unlikely for one session.
*/
export function generatePageId(): string {
const a = Math.random().toString(36).substring(2, 10)
const b = Math.random().toString(36).substring(2, 6)
return `${a}-${b}`
}
/** Cheap regex check — does the XML start with an <mxfile> root? */
export function isMxFile(xml: string): boolean {
return /^\s*(<\?xml[^>]*\?>\s*)?<mxfile[\s>]/i.test(xml)
}
/** Cheap regex check — does the XML start with a bare <mxGraphModel>? */
export function isMxGraphModel(xml: string): boolean {
return /^\s*(<\?xml[^>]*\?>\s*)?<mxGraphModel[\s>]/i.test(xml)
}
function escapeAttr(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
}
/**
* Strip a leading <?xml ... ?> declaration from an XML string. The XML spec
* only permits the declaration at the very start of a document, so embedding
* a declaration inside another element produces invalid XML. Callers must
* strip before splicing a fragment into a wrapper.
*/
function stripXmlDeclaration(xml: string): string {
return xml.replace(/^\s*<\?xml[^>]*\?>\s*/i, "")
}
/**
* Wrap a bare <mxGraphModel> XML string in <mxfile><diagram>...</diagram></mxfile>.
* If the input is already an mxfile, returns it unchanged.
* If the input is neither shape, returns null so the caller can surface a clear error.
*
* Strips any leading <?xml ?> declaration before embedding — a declaration is
* only valid at the very start of a document, never inside a <diagram>.
*/
export function normalizeToMxfile(
xml: string,
opts: { pageId?: string; pageName?: string; host?: string } = {},
): string | null {
const trimmed = xml.trim()
if (!trimmed) return null
if (isMxFile(trimmed)) return trimmed
if (!isMxGraphModel(trimmed)) return null
const pageId = opts.pageId || generatePageId()
const pageName = opts.pageName || "Page-1"
const host = opts.host || "app.diagrams.net"
const inner = stripXmlDeclaration(trimmed)
return `<mxfile host="${escapeAttr(host)}"><diagram id="${escapeAttr(pageId)}" name="${escapeAttr(pageName)}">${inner}</diagram></mxfile>`
}
/**
* Parse an mxfile XML string. Returns null on parse error or if the root
* isn't <mxfile> — callers are expected to have run normalizeToMxfile first.
*/
export function parseMxfile(xml: string): Document | null {
try {
const doc = new DOMParser().parseFromString(xml, "text/xml")
if (doc.querySelector("parsererror")) return null
if (doc.documentElement?.tagName !== "mxfile") return null
return doc as unknown as Document
} catch {
return null
}
}
/** Serialise an mxfile doc back to a string via the global XMLSerializer polyfill. */
export function serializeMxfile(doc: Document): string {
const serializer = new XMLSerializer()
return serializer.serializeToString(doc)
}
export type PageProjection =
| { ok: true; xml: string; index: number; name: string }
| { ok: false; reason: "parse" | "notfound" }
/**
* Project a single page out of an mxfile string into a standalone one-page
* <mxfile>. Used by get_diagram and export_diagram so the three call sites
* share one parse → find → serialise path.
*
* Returns { ok:false, reason:"parse" } if the xml isn't a parseable mxfile,
* or { ok:false, reason:"notfound" } if the selector matches no page.
*/
export function projectPage(
xml: string,
selector: PageSelector,
): PageProjection {
const doc = parseMxfile(xml)
if (!doc) return { ok: false, reason: "parse" }
const found = findPageElement(doc, selector)
if (!found) return { ok: false, reason: "notfound" }
const serializer = new XMLSerializer()
return {
ok: true,
xml: `<mxfile host="app.diagrams.net">${serializer.serializeToString(found.element)}</mxfile>`,
index: found.index,
name: found.element.getAttribute("name") || "",
}
}
/** Walk every <diagram> child of <mxfile> and return summary info. */
export function listPagesFromDoc(doc: Document): PageInfo[] {
const diagrams = doc.querySelectorAll("diagram")
const result: PageInfo[] = []
diagrams.forEach((d, idx) => {
const root = d.querySelector("root")
const cellCount = root ? root.querySelectorAll("mxCell").length : 0
result.push({
id: d.getAttribute("id") || "",
name: d.getAttribute("name") || `Page-${idx + 1}`,
index: idx,
cellCount,
})
})
return result
}
/**
* Resolve a page selector to its <diagram> element.
* Resolution order: page_id → page_name → page_index → default (first page).
*
* When no selector field is set we return the first page — the "active page
* by convention" mentioned in §3.4 of the design doc.
*/
export function findPageElement(
doc: Document,
selector?: PageSelector,
): { element: Element; index: number } | null {
const diagrams = Array.from(doc.querySelectorAll("diagram"))
if (diagrams.length === 0) return null
if (!hasPageSelector(selector)) {
return { element: diagrams[0], index: 0 }
}
if (selector?.page_id) {
for (let i = 0; i < diagrams.length; i++) {
if (diagrams[i].getAttribute("id") === selector.page_id) {
return { element: diagrams[i], index: i }
}
}
return null
}
if (selector?.page_name) {
for (let i = 0; i < diagrams.length; i++) {
if (diagrams[i].getAttribute("name") === selector.page_name) {
return { element: diagrams[i], index: i }
}
}
return null
}
if (selector && selector.page_index !== undefined) {
const idx = selector.page_index
if (Number.isInteger(idx) && idx >= 0 && idx < diagrams.length) {
return { element: diagrams[idx], index: idx }
}
return null
}
return null
}
/**
* Append a new <diagram> to the mxfile doc. The new page's model defaults to
* an empty <mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel>.
*
* `opts.xml` must be a BARE <mxGraphModel> — passing a full <mxfile> would
* end up nested inside <diagram>, which is malformed. We reject the mxfile
* shape explicitly and strip any <?xml ?> declaration (only valid at
* document start, never inside <diagram>).
*
* Returns the new PageInfo. Throws if the requested id collides or the xml
* shape is wrong.
*/
export function addPageToDoc(
doc: Document,
opts: { id?: string; name?: string; xml?: string } = {},
): PageInfo {
const existing = listPagesFromDoc(doc)
const id = opts.id || generatePageId()
if (existing.some((p) => p.id === id)) {
throw new Error(`Page id "${id}" already exists`)
}
const name = opts.name || `Page-${existing.length + 1}`
let inner: string
if (opts.xml?.trim()) {
const trimmed = stripXmlDeclaration(opts.xml.trim())
if (isMxFile(trimmed)) {
throw new Error(
"addPageToDoc: opts.xml must be a bare <mxGraphModel>; received a full <mxfile>. Extract the target diagram's <mxGraphModel> first.",
)
}
if (!isMxGraphModel(trimmed)) {
throw new Error(
"addPageToDoc: opts.xml must be a bare <mxGraphModel>.",
)
}
inner = trimmed
} else {
inner = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel>`
}
const snippet = `<wrapper><diagram id="${escapeAttr(id)}" name="${escapeAttr(name)}">${inner}</diagram></wrapper>`
const tempDoc = new DOMParser().parseFromString(snippet, "text/xml")
if (tempDoc.querySelector("parsererror")) {
throw new Error(
"Failed to parse new page xml — make sure it is a valid <mxGraphModel>",
)
}
const newDiagram = tempDoc.querySelector("diagram")
if (!newDiagram) {
throw new Error("Failed to construct <diagram> element for new page")
}
const imported = doc.importNode(newDiagram, true) as Element
doc.documentElement.appendChild(imported)
return {
id,
name,
index: existing.length,
cellCount: imported.querySelectorAll("mxCell").length,
}
}
/** Rename the page matched by selector. Returns true on success. */
export function renamePageInDoc(
doc: Document,
selector: PageSelector,
newName: string,
): boolean {
const found = findPageElement(doc, selector)
if (!found) return false
found.element.setAttribute("name", newName)
return true
}
/**
* Delete a page. Refuses to delete the last remaining page — the embed needs
* at least one diagram to render anything, and silently recreating one would
* be surprising behaviour for an MCP caller.
*/
export function deletePageFromDoc(
doc: Document,
selector: PageSelector,
): { ok: boolean; reason?: string; deletedId?: string; deletedIndex?: number } {
const pages = listPagesFromDoc(doc)
if (pages.length <= 1) {
return { ok: false, reason: "Cannot delete the only remaining page" }
}
const found = findPageElement(doc, selector)
if (!found) {
return { ok: false, reason: "Page not found" }
}
const id = found.element.getAttribute("id") || ""
const index = found.index
found.element.parentNode?.removeChild(found.element)
return { ok: true, deletedId: id, deletedIndex: index }
}

View File

@@ -119,74 +119,8 @@ function checkDuplicateAttributes(xml: string): string | null {
return null
}
/**
* Check for duplicate IDs in XML.
*
* For multi-page documents (<mxfile> with multiple <diagram> children), cell
* IDs are unique **within a page**, not across the whole document — drawio
* legitimately reuses "0" and "1" for the root cells of every page. So we
* scope the cell-ID uniqueness check per <diagram>, and additionally check
* that the <diagram> ids themselves are unique.
*
* The legacy regex-based check is kept as a fallback for non-mxfile inputs
* and for XML that won't DOM-parse.
*/
/** Check for duplicate IDs in XML */
function checkDuplicateIds(xml: string): string | null {
// The DOM-aware path only matters for <mxfile> wrappers; for legacy
// bare <mxGraphModel> inputs (the overwhelming majority of historic
// traffic), the cheap regex fallback at the bottom is enough. A quick
// string check avoids paying the DOMParser cost on every call.
const mightBeMxFile = /<mxfile[\s>]/i.test(xml)
// Try DOM-aware, page-scoped check first when the input looks mxfile-ish.
if (mightBeMxFile)
try {
const doc = new DOMParser().parseFromString(xml, "text/xml")
if (!doc.querySelector("parsererror")) {
const rootEl = doc.documentElement
if (rootEl && rootEl.tagName === "mxfile") {
const diagrams = doc.querySelectorAll("diagram")
// 1) <diagram> ids must be unique across the file.
const diagramIds = new Map<string, number>()
diagrams.forEach((d) => {
const id = d.getAttribute("id")
if (id)
diagramIds.set(id, (diagramIds.get(id) || 0) + 1)
})
const dupDiagrams = Array.from(diagramIds.entries())
.filter(([, c]) => c > 1)
.map(([id]) => `'${id}'`)
if (dupDiagrams.length > 0) {
return `Invalid XML: Found duplicate <diagram> id(s): ${dupDiagrams.slice(0, 3).join(", ")}. Each page must have a unique id.`
}
// 2) Within each page, mxCell ids must be unique.
for (let i = 0; i < diagrams.length; i++) {
const diagram = diagrams[i]
const pageId =
diagram.getAttribute("id") || `(index ${i})`
const cells = diagram.querySelectorAll("mxCell")
const cellIds = new Map<string, number>()
cells.forEach((c) => {
const id = c.getAttribute("id")
if (id) cellIds.set(id, (cellIds.get(id) || 0) + 1)
})
const dups = Array.from(cellIds.entries())
.filter(([, c]) => c > 1)
.map(([id, count]) => `'${id}' (${count}x)`)
if (dups.length > 0) {
return `Invalid XML: Found duplicate cell ID(s) in page "${pageId}": ${dups.slice(0, 3).join(", ")}. All mxCell ids must be unique within a page.`
}
}
return null
}
}
} catch {
// fall through to regex
}
// Legacy regex-based check for bare <mxGraphModel> and parse-error cases.
const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi
const ids = new Map<string, number>()
let idMatch
@@ -836,46 +770,35 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
fixes.push(`Fixed ${trueNestedFixed} true nested mxCell(s)`)
}
// 22. Fix duplicate IDs by appending suffix.
// Skipped for multi-page <mxfile> documents — cell ids "0" and "1" repeat
// across pages legitimately (every page has its own <root> with id="0"/"1"
// sentinel cells). Renaming them would break drawio's parent references.
// For mxfile inputs, duplicate-id validation is page-scoped in
// checkDuplicateIds() and a true duplicate produces a hard error rather
// than a silent rename.
if (!/<mxfile[\s>]/i.test(fixed)) {
const seenIds = new Map<string, number>()
const duplicateIds: string[] = []
// 22. Fix duplicate IDs by appending suffix
const seenIds = new Map<string, number>()
const duplicateIds: string[] = []
const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi
let idMatch
while ((idMatch = idPattern.exec(fixed)) !== null) {
const id = idMatch[1]
seenIds.set(id, (seenIds.get(id) || 0) + 1)
}
const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi
let idMatch
while ((idMatch = idPattern.exec(fixed)) !== null) {
const id = idMatch[1]
seenIds.set(id, (seenIds.get(id) || 0) + 1)
}
for (const [id, count] of seenIds) {
if (count > 1) duplicateIds.push(id)
}
for (const [id, count] of seenIds) {
if (count > 1) duplicateIds.push(id)
}
if (duplicateIds.length > 0) {
const idCounters = new Map<string, number>()
fixed = fixed.replace(
/\bid\s*=\s*["']([^"']+)["']/gi,
(match, id) => {
if (!duplicateIds.includes(id)) return match
if (duplicateIds.length > 0) {
const idCounters = new Map<string, number>()
fixed = fixed.replace(/\bid\s*=\s*["']([^"']+)["']/gi, (match, id) => {
if (!duplicateIds.includes(id)) return match
const count = idCounters.get(id) || 0
idCounters.set(id, count + 1)
const count = idCounters.get(id) || 0
idCounters.set(id, count + 1)
if (count === 0) return match
if (count === 0) return match
const newId = `${id}_dup${count}`
return match.replace(id, newId)
},
)
fixes.push(`Renamed ${duplicateIds.length} duplicate ID(s)`)
}
const newId = `${id}_dup${count}`
return match.replace(id, newId)
})
fixes.push(`Renamed ${duplicateIds.length} duplicate ID(s)`)
}
// 23. Fix empty id attributes

View File

@@ -1,545 +0,0 @@
/**
* Unit tests for multi-page (mxfile) support.
*
* Pinned to the user-visible contract described in
* multi-page-mcp-support-plan.md §5 (acceptance criteria):
*
* AC1. create_new_diagram accepts both bare <mxGraphModel> and full <mxfile>.
* AC2. get_diagram returns the full <mxfile> regardless of page count.
* AC3. edit_diagram accepts an optional page selector.
* AC6. Two tool calls reproduce the Transformer/CNN scenario.
* AC9. The wrapper-injection hack at http-server.ts:845 is unnecessary.
*
* These tests pin the helpers (pages.ts), the validator update
* (xml-validation.ts), and the page-targeted edit logic
* (diagram-operations.ts) — i.e. the layers underneath the MCP tool surface.
*/
import { DOMParser } from "linkedom"
import { beforeAll, describe, expect, it } from "vitest"
// Install the DOM polyfill exactly as index.ts does at runtime — the
// helpers under test rely on it.
beforeAll(() => {
;(globalThis as any).DOMParser = DOMParser
class XMLSerializerPolyfill {
serializeToString(node: any): string {
if (node.outerHTML !== undefined) return node.outerHTML
if (node.documentElement) return node.documentElement.outerHTML
return ""
}
}
;(globalThis as any).XMLSerializer = XMLSerializerPolyfill
})
import { applyDiagramOperations } from "../src/diagram-operations.js"
import {
addPageToDoc,
deletePageFromDoc,
findPageElement,
generatePageId,
hasPageSelector,
isMxFile,
isMxGraphModel,
listPagesFromDoc,
normalizeToMxfile,
parseMxfile,
projectPage,
renamePageInDoc,
serializeMxfile,
} from "../src/pages.js"
import { validateAndFixXml } from "../src/xml-validation.js"
const BARE_MODEL_ONE_CELL = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Hello"><mxGeometry x="40" y="40" width="100" height="40" as="geometry"/></mxCell></root></mxGraphModel>`
const TWO_PAGE_MXFILE = `<mxfile host="app.diagrams.net"><diagram id="page-transformer" name="Transformer"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Encoder"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel></diagram><diagram id="page-cnn" name="CNN"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Conv1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`
describe("pages.ts — shape detection", () => {
it("isMxFile detects a multi-page mxfile", () => {
expect(isMxFile(TWO_PAGE_MXFILE)).toBe(true)
})
it("isMxFile rejects a bare mxGraphModel", () => {
expect(isMxFile(BARE_MODEL_ONE_CELL)).toBe(false)
})
it("isMxGraphModel detects a bare model", () => {
expect(isMxGraphModel(BARE_MODEL_ONE_CELL)).toBe(true)
expect(isMxGraphModel(TWO_PAGE_MXFILE)).toBe(false)
})
it("isMxFile tolerates an XML declaration prefix", () => {
expect(
isMxFile(
`<?xml version="1.0" encoding="UTF-8"?>${TWO_PAGE_MXFILE}`,
),
).toBe(true)
})
})
describe("pages.ts — normalizeToMxfile (backward compatibility, AC1)", () => {
it("wraps a bare mxGraphModel into a single-page mxfile", () => {
const out = normalizeToMxfile(BARE_MODEL_ONE_CELL, {
pageId: "p1",
pageName: "Page-1",
})
expect(out).not.toBeNull()
expect(out).toMatch(/^<mxfile/)
expect(out).toContain(`<diagram id="p1" name="Page-1">`)
expect(out).toContain("<mxGraphModel>")
})
it("returns mxfile inputs unchanged", () => {
const out = normalizeToMxfile(TWO_PAGE_MXFILE)
expect(out).toBe(TWO_PAGE_MXFILE)
})
it("returns null for neither shape", () => {
expect(normalizeToMxfile("<random/>")).toBeNull()
expect(normalizeToMxfile("")).toBeNull()
})
it("generated page ids look reasonable", () => {
for (let i = 0; i < 50; i++) {
const id = generatePageId()
expect(id).toMatch(/^[a-z0-9]+-[a-z0-9]+$/)
}
})
it("strips a leading <?xml ?> declaration when wrapping a bare model", () => {
// Regression for the bug Copilot caught: isMxGraphModel tolerates a
// declaration prefix, but the wrapper used to embed it inside
// <diagram>, producing invalid XML (<?xml ?> is only valid at the
// document start). The result must round-trip through parseMxfile
// and the declaration must be gone from inside <diagram>.
const withDecl = `<?xml version="1.0" encoding="UTF-8"?>${BARE_MODEL_ONE_CELL}`
const out = normalizeToMxfile(withDecl, {
pageId: "p1",
pageName: "Page-1",
})
expect(out).not.toBeNull()
expect(out).toMatch(/^<mxfile/)
// No <?xml inside the body of the wrapped document.
expect(out!.indexOf("<?xml")).toBe(-1)
// And it must still parse cleanly.
const doc = parseMxfile(out!)
expect(doc).not.toBeNull()
expect(listPagesFromDoc(doc!)).toHaveLength(1)
})
})
describe("pages.ts — addPageToDoc input validation", () => {
it("rejects opts.xml shaped as a full <mxfile>", () => {
// Regression for the Copilot-flagged bug: an mxfile passed as
// starting page xml would end up nested inside <diagram>, corrupting
// the document. Must throw with a clear message.
const doc = parseMxfile(TWO_PAGE_MXFILE)!
expect(() =>
addPageToDoc(doc, { name: "Bad", xml: TWO_PAGE_MXFILE }),
).toThrowError(/bare <mxGraphModel>/i)
})
it("rejects opts.xml that is neither mxGraphModel nor mxfile", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
expect(() =>
addPageToDoc(doc, { name: "Junk", xml: "<root><x/></root>" }),
).toThrowError(/bare <mxGraphModel>/i)
})
it("strips a <?xml ?> declaration prefix on opts.xml", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
const withDecl = `<?xml version="1.0"?>${BARE_MODEL_ONE_CELL}`
const info = addPageToDoc(doc, { name: "Sequence", xml: withDecl })
expect(info.cellCount).toBeGreaterThanOrEqual(3)
// Serialised document must not have <?xml ?> inside <diagram>.
const out = serializeMxfile(doc)
// The mxfile may have one <?xml ?> at the very start (the doc decl),
// but no further occurrence inside <diagram>.
const matches = out.match(/<\?xml/g) || []
expect(matches.length).toBeLessThanOrEqual(1)
})
})
describe("pages.ts — listPagesFromDoc / findPageElement", () => {
it("lists both pages in a two-page mxfile", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
const pages = listPagesFromDoc(doc)
expect(pages).toHaveLength(2)
expect(pages[0]).toMatchObject({
id: "page-transformer",
name: "Transformer",
index: 0,
})
expect(pages[1]).toMatchObject({
id: "page-cnn",
name: "CNN",
index: 1,
})
// Cell count is per-page (3 cells per page including the two root sentinels).
expect(pages[0].cellCount).toBe(3)
expect(pages[1].cellCount).toBe(3)
})
it("findPageElement defaults to the first page when selector is empty", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
const found = findPageElement(doc)
expect(found?.index).toBe(0)
expect(found?.element.getAttribute("id")).toBe("page-transformer")
})
it("findPageElement matches by id, name, and index — id wins when several are set", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
expect(findPageElement(doc, { page_id: "page-cnn" })?.index).toBe(1)
expect(findPageElement(doc, { page_name: "CNN" })?.index).toBe(1)
expect(findPageElement(doc, { page_index: 1 })?.index).toBe(1)
// id beats name beats index
const winner = findPageElement(doc, {
page_id: "page-cnn",
page_name: "Transformer",
page_index: 0,
})
expect(winner?.index).toBe(1)
})
it("findPageElement returns null for an unknown selector", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
expect(findPageElement(doc, { page_id: "ghost" })).toBeNull()
expect(findPageElement(doc, { page_name: "ghost" })).toBeNull()
expect(findPageElement(doc, { page_index: 99 })).toBeNull()
expect(findPageElement(doc, { page_index: -1 })).toBeNull()
})
it("hasPageSelector correctly detects empty vs populated selectors", () => {
expect(hasPageSelector()).toBe(false)
expect(hasPageSelector({})).toBe(false)
expect(hasPageSelector({ page_id: "x" })).toBe(true)
expect(hasPageSelector({ page_index: 0 })).toBe(true)
})
})
describe("pages.ts — addPageToDoc", () => {
it("appends a third page and returns its info", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
const info = addPageToDoc(doc, { name: "Sequence" })
expect(info.name).toBe("Sequence")
expect(info.index).toBe(2)
expect(info.id).toMatch(/.+/)
const pages = listPagesFromDoc(doc)
expect(pages).toHaveLength(3)
expect(pages[2].name).toBe("Sequence")
})
it("rejects a duplicate explicit id", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
expect(() =>
addPageToDoc(doc, { id: "page-transformer", name: "X" }),
).toThrowError(/already exists/)
})
it("uses a sensible default name when none is supplied", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
const info = addPageToDoc(doc, {})
expect(info.name).toBe("Page-3")
})
it("accepts an inline starting mxGraphModel", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
const inner = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="A"><mxGeometry x="10" y="10" width="20" height="20" as="geometry"/></mxCell></root></mxGraphModel>`
const info = addPageToDoc(doc, { name: "Custom", xml: inner })
expect(info.cellCount).toBeGreaterThanOrEqual(3)
})
})
describe("pages.ts — renamePageInDoc / deletePageFromDoc", () => {
it("renames an existing page by name", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
const ok = renamePageInDoc(doc, { page_name: "CNN" }, "CNN-v2")
expect(ok).toBe(true)
const pages = listPagesFromDoc(doc)
expect(pages[1].name).toBe("CNN-v2")
})
it("rename returns false when target page is missing", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
expect(renamePageInDoc(doc, { page_id: "ghost" }, "Z")).toBe(false)
})
it("deletes a page and removes the <diagram> element from the doc", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
const outcome = deletePageFromDoc(doc, { page_id: "page-cnn" })
expect(outcome.ok).toBe(true)
expect(outcome.deletedId).toBe("page-cnn")
expect(listPagesFromDoc(doc)).toHaveLength(1)
})
it("refuses to delete the only remaining page", () => {
// Build a single-page doc to test the guard.
const single = normalizeToMxfile(BARE_MODEL_ONE_CELL)!
const doc = parseMxfile(single)!
const outcome = deletePageFromDoc(doc, { page_index: 0 })
expect(outcome.ok).toBe(false)
expect(outcome.reason).toMatch(/only remaining page/)
})
})
describe("xml-validation.ts — multi-page support", () => {
it("accepts a valid two-page mxfile (the exact payload that used to fail)", () => {
const result = validateAndFixXml(TWO_PAGE_MXFILE)
expect(result.valid).toBe(true)
expect(result.error).toBeNull()
})
it("does NOT flag root sentinel ids 0 and 1 repeating across pages", () => {
// This is the regression the planning doc explicitly called out:
// before this work, the legacy regex-based duplicate-id check rejected
// any multi-page document because cells "0" and "1" appear in every page.
const result = validateAndFixXml(TWO_PAGE_MXFILE)
expect(result.valid).toBe(true)
})
it("rejects duplicate cell ids WITHIN a single page", () => {
const bad = `<mxfile host="app.diagrams.net"><diagram id="p1" name="P1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="dup" vertex="1" parent="1"/><mxCell id="dup" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
const result = validateAndFixXml(bad)
expect(result.valid).toBe(false)
expect(result.error).toMatch(/duplicate cell ID/i)
})
it("rejects duplicate <diagram> ids across the file", () => {
const bad = `<mxfile host="app.diagrams.net"><diagram id="p1" name="A"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram><diagram id="p1" name="B"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
const result = validateAndFixXml(bad)
expect(result.valid).toBe(false)
expect(result.error).toMatch(/duplicate <diagram> id/i)
})
it("still validates a bare <mxGraphModel> (legacy callers)", () => {
const result = validateAndFixXml(BARE_MODEL_ONE_CELL)
expect(result.valid).toBe(true)
})
it("auto-fix does NOT rename mxfile root cells 0/1 (would break drawio refs)", () => {
// Build a doc that triggers some other auto-fix (so autoFixXml runs)
// but contains valid multi-page 0/1 cells that must NOT be renamed.
const malformedButMultiPage = `<mxfile host="app.diagrams.net"><diagram id="p1" name="A"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Q & A"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel></diagram><diagram id="p2" name="B"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
const result = validateAndFixXml(malformedButMultiPage)
// The doc has an unescaped & — autoFix will repair that. After repair
// it should be valid AND must not have renamed the 0/1 cells.
const finalXml = result.fixed || malformedButMultiPage
expect(finalXml).not.toMatch(/id="0_dup/)
expect(finalXml).not.toMatch(/id="1_dup/)
})
})
describe("diagram-operations.ts — page-targeted edits (AC3)", () => {
it("adds a cell to the targeted page by id, leaving the other page untouched", () => {
const { result, errors } = applyDiagramOperations(
TWO_PAGE_MXFILE,
[
{
operation: "add",
cell_id: "conv-2",
new_xml: `<mxCell id="conv-2" vertex="1" parent="1" value="Conv2"><mxGeometry x="200" y="40" width="120" height="60" as="geometry"/></mxCell>`,
},
],
{ page_id: "page-cnn" },
)
expect(errors).toHaveLength(0)
const doc = parseMxfile(result)!
const pages = listPagesFromDoc(doc)
// Transformer untouched (still 3 cells), CNN gained one cell.
expect(pages[0].cellCount).toBe(3)
expect(pages[1].cellCount).toBe(4)
expect(result).toContain(`id="conv-2"`)
})
it("defaults to the first page when no selector is given", () => {
const { result, errors } = applyDiagramOperations(TWO_PAGE_MXFILE, [
{
operation: "add",
cell_id: "shape-x",
new_xml: `<mxCell id="shape-x" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>`,
},
])
expect(errors).toHaveLength(0)
const doc = parseMxfile(result)!
const pages = listPagesFromDoc(doc)
expect(pages[0].cellCount).toBe(4) // Transformer (first page) grew
expect(pages[1].cellCount).toBe(3) // CNN untouched
})
it("errors clearly when the page is not found", () => {
const { errors } = applyDiagramOperations(
TWO_PAGE_MXFILE,
[
{
operation: "delete",
cell_id: "2",
},
],
{ page_id: "does-not-exist" },
)
expect(errors).toHaveLength(1)
expect(errors[0].message).toMatch(/Page.*not found/i)
// Page-level errors carry an empty cellId — edit_diagram relies on
// this to distinguish "nothing applied" from per-cell warnings and
// return a hard error instead of a false success.
expect(errors[0].cellId).toBe("")
})
it("delete on page 2 does NOT touch page 1's mxCell with the same id", () => {
// Both pages have a cell with id="2". A delete on CNN's "2" must not
// remove Transformer's "2".
const { result, errors } = applyDiagramOperations(
TWO_PAGE_MXFILE,
[{ operation: "delete", cell_id: "2" }],
{ page_id: "page-cnn" },
)
expect(errors).toHaveLength(0)
const doc = parseMxfile(result)!
const pages = listPagesFromDoc(doc)
// CNN lost its only non-sentinel cell, Transformer keeps its three.
expect(pages[1].cellCount).toBe(2)
expect(pages[0].cellCount).toBe(3)
})
it("legacy bare-mxGraphModel input still works when no selector is given", () => {
const { result, errors } = applyDiagramOperations(BARE_MODEL_ONE_CELL, [
{
operation: "add",
cell_id: "new",
new_xml: `<mxCell id="new" vertex="1" parent="1"><mxGeometry x="100" y="100" width="50" height="50" as="geometry"/></mxCell>`,
},
])
expect(errors).toHaveLength(0)
expect(result).toContain(`id="new"`)
})
it("page selector on a bare mxGraphModel returns a clear error", () => {
const { errors } = applyDiagramOperations(
BARE_MODEL_ONE_CELL,
[{ operation: "delete", cell_id: "2" }],
{ page_id: "page-1" },
)
expect(errors).toHaveLength(1)
expect(errors[0].message).toMatch(/not multi-page/i)
})
})
describe("export_diagram — single-page projection (regression for selectPage bug)", () => {
// The previous implementation tried to drive drawio's iframe with an
// `action: 'selectPage'` postMessage, which the embed protocol silently
// ignores. The result was that PNG/SVG exports targeted the currently
// active tab regardless of the page selector — two visually different
// pages would yield byte-identical PNGs.
//
// The current implementation builds a single-page <mxfile> projection via
// the shared pages.ts:projectPage helper and hands it to the browser
// bridge to load BEFORE triggering export. These tests pin that helper so
// a future refactor can't silently re-introduce the multi-page drift.
function projectSinglePage(fullMxfile: string, sel: any): string {
const result = projectPage(fullMxfile, sel)
if (!result.ok) throw new Error(`projection failed: ${result.reason}`)
return result.xml
}
it("returns a parse error for a non-mxfile source", () => {
const result = projectPage(BARE_MODEL_ONE_CELL, { page_id: "x" })
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toBe("parse")
})
it("returns a notfound error for an unknown page", () => {
const result = projectPage(TWO_PAGE_MXFILE, { page_id: "ghost" })
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toBe("notfound")
})
it("projects only the requested page when targeted by id", () => {
const projected = projectSinglePage(TWO_PAGE_MXFILE, {
page_id: "page-cnn",
})
const pages = listPagesFromDoc(parseMxfile(projected)!)
expect(pages).toHaveLength(1)
expect(pages[0].id).toBe("page-cnn")
expect(pages[0].name).toBe("CNN")
// The projection must NOT contain the Transformer page anywhere.
expect(projected).not.toContain('id="page-transformer"')
expect(projected).not.toContain('name="Transformer"')
})
it("projects only the requested page when targeted by name", () => {
const projected = projectSinglePage(TWO_PAGE_MXFILE, {
page_name: "Transformer",
})
const pages = listPagesFromDoc(parseMxfile(projected)!)
expect(pages).toHaveLength(1)
expect(pages[0].name).toBe("Transformer")
expect(projected).not.toContain('id="page-cnn"')
})
it("projects only the requested page when targeted by index", () => {
const projected = projectSinglePage(TWO_PAGE_MXFILE, {
page_index: 1,
})
const pages = listPagesFromDoc(parseMxfile(projected)!)
expect(pages).toHaveLength(1)
expect(pages[0].index).toBe(0) // re-indexed: it's the only page in the projection
expect(pages[0].id).toBe("page-cnn")
})
it("two different page selectors produce visually distinct projections", () => {
// The regression: under the old selectPage bug, two exports would
// return the same active tab. With the projection approach, the
// payload that drawio renders is provably different.
const a = projectSinglePage(TWO_PAGE_MXFILE, {
page_id: "page-transformer",
})
const b = projectSinglePage(TWO_PAGE_MXFILE, { page_id: "page-cnn" })
expect(a).not.toBe(b)
expect(a).toContain('"Encoder"')
expect(a).not.toContain('"Conv1"')
expect(b).toContain('"Conv1"')
expect(b).not.toContain('"Encoder"')
})
it("the projection parses to a valid one-page mxfile", () => {
const projected = projectSinglePage(TWO_PAGE_MXFILE, {
page_id: "page-cnn",
})
// Validator accepts it.
expect(validateAndFixXml(projected).valid).toBe(true)
// And it has a real <root> with the cells from the source page.
const doc = parseMxfile(projected)!
const root = doc.querySelector("root")
expect(root).not.toBeNull()
const conv1 = doc.querySelector('mxCell[value="Conv1"]')
expect(conv1).not.toBeNull()
})
})
describe("end-to-end — Transformer + CNN scenario (AC6)", () => {
it("two tool-equivalent steps reproduce the motivating user scenario", () => {
// Step 1 — caller passes a single-page mxfile.
const step1 = normalizeToMxfile(BARE_MODEL_ONE_CELL, {
pageId: "page-transformer",
pageName: "Transformer",
})
expect(step1).not.toBeNull()
let xml = step1 as string
const validate1 = validateAndFixXml(xml)
expect(validate1.valid).toBe(true)
// Step 2 — equivalent of add_page("CNN") with a starting model.
const doc = parseMxfile(xml)!
addPageToDoc(doc, {
id: "page-cnn",
name: "CNN",
xml: `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Conv1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`,
})
xml = serializeMxfile(doc)
// Now: two pages, both valid, with the right names.
const pages = listPagesFromDoc(parseMxfile(xml)!)
expect(pages.map((p) => p.name)).toEqual(["Transformer", "CNN"])
expect(validateAndFixXml(xml).valid).toBe(true)
})
})

View File

@@ -1,141 +0,0 @@
/**
* Server-wiring test: boot the actual MCP stdio server (from source via tsx)
* and drive it the way a real MCP client does — initialize handshake,
* tools/list — to catch registration/schema regressions that the unit tests
* (which import helpers directly) can't see.
*
* This replaces the old standalone tests/smoke.mjs, which spawned the BUILT
* dist/index.js and was therefore never run in CI (CI doesn't build this
* package before testing). Running from source via tsx means it executes as
* part of the normal `vitest run`.
*
* We deliberately do NOT call start_session — it would open a real browser
* window via open(). The browser bridge is covered by the Playwright e2e suite.
*/
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { afterAll, beforeAll, describe, expect, it } from "vitest"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const entry = path.resolve(__dirname, "..", "src", "index.ts")
const tsxBin = path.resolve(
__dirname,
"..",
"node_modules",
".bin",
process.platform === "win32" ? "tsx.cmd" : "tsx",
)
const EXPECTED_TOOLS = [
"start_session",
"create_new_diagram",
"edit_diagram",
"get_diagram",
"export_diagram",
"list_pages",
"add_page",
"rename_page",
"delete_page",
]
let proc: ChildProcessWithoutNullStreams
let stdoutBuf = ""
const pending = new Map<
number,
{ resolve: (m: any) => void; reject: (e: Error) => void; timeout: any }
>()
let nextId = 1
function send(method: string, params: unknown, isNotification = false) {
const msg: Record<string, unknown> = { jsonrpc: "2.0", method, params }
if (!isNotification) msg.id = nextId++
proc.stdin.write(`${JSON.stringify(msg)}\n`)
if (isNotification) return Promise.resolve(undefined)
return new Promise<any>((resolve, reject) => {
const id = msg.id as number
const timeout = setTimeout(() => {
pending.delete(id)
reject(new Error(`Timed out waiting for response to ${method}`))
}, 15000)
pending.set(id, { resolve, reject, timeout })
})
}
beforeAll(async () => {
proc = spawn(tsxBin, [entry], {
stdio: ["pipe", "pipe", "pipe"],
}) as ChildProcessWithoutNullStreams
proc.stdout.on("data", (chunk: Buffer) => {
stdoutBuf += chunk.toString()
const lines = stdoutBuf.split("\n")
stdoutBuf = lines.pop() || ""
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) continue
let msg: any
try {
msg = JSON.parse(trimmed)
} catch {
// Non-JSON-RPC log line — ignore.
continue
}
const p = msg.id !== undefined ? pending.get(msg.id) : undefined
if (p) {
clearTimeout(p.timeout)
pending.delete(msg.id)
p.resolve(msg)
}
}
})
const initResp = await send("initialize", {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "wiring-test", version: "0.0.0" },
})
expect(initResp.error, JSON.stringify(initResp.error)).toBeUndefined()
expect(initResp.result?.serverInfo?.name).toBeTruthy()
await send("notifications/initialized", {}, true)
}, 30000)
afterAll(() => {
proc?.kill("SIGTERM")
})
describe("MCP server wiring", () => {
it("registers all nine multi-page tools", async () => {
const resp = await send("tools/list", {})
expect(resp.error, JSON.stringify(resp.error)).toBeUndefined()
const names: string[] = (resp.result?.tools ?? []).map(
(t: { name: string }) => t.name,
)
for (const expected of EXPECTED_TOOLS) {
expect(names, `missing tool: ${expected}`).toContain(expected)
}
})
it("advertises page-selector params on edit_diagram", async () => {
const resp = await send("tools/list", {})
const edit = resp.result.tools.find(
(t: { name: string }) => t.name === "edit_diagram",
)
const props = edit?.inputSchema?.properties ?? {}
expect(props.page_id).toBeTruthy()
expect(props.page_name).toBeTruthy()
expect(props.page_index).toBeTruthy()
})
it("advertises name/id/xml on add_page", async () => {
const resp = await send("tools/list", {})
const addPage = resp.result.tools.find(
(t: { name: string }) => t.name === "add_page",
)
const props = addPage?.inputSchema?.properties ?? {}
expect(props.name).toBeTruthy()
expect(props.id).toBeTruthy()
expect(props.xml).toBeTruthy()
})
})

View File

@@ -1,11 +0,0 @@
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
include: ["tests/**/*.test.ts"],
environment: "node",
// The package source uses Node16 module resolution with explicit .js
// extensions in imports. Vitest+esbuild handles the .ts→.js mapping
// transparently, so no extra alias config is needed.
},
})

View File

@@ -1,36 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import {
getAIModel,
isAihubmixStandardBaseURL,
resolveBaseURL,
supportsImageInput,
supportsPromptCaching,
} from "@/lib/ai-providers"
import { extractAihubmixModelIds } from "@/lib/aihubmix-models"
describe("extractAihubmixModelIds", () => {
it("extracts unique chat model IDs from the AIHubMix model list payload", () => {
const models = extractAihubmixModelIds({
data: [
{ model_id: "claude-sonnet-4-5-20250929", types: "llm" },
{ model_id: "gpt-5.1", types: "llm" },
{ model_id: "gpt-5.1", types: "llm" },
{ model_id: "gpt-image-2", types: "image_generation,llm" },
{ model_id: "cohere-rerank-v4.0", types: "rerank" },
{ model_id: "", types: "llm" },
{ types: "llm" },
],
})
expect(models).toEqual(["claude-sonnet-4-5-20250929", "gpt-5.1"])
})
it("returns an empty list for malformed payloads", () => {
expect(extractAihubmixModelIds({ data: null })).toEqual([])
expect(extractAihubmixModelIds({})).toEqual([])
expect(extractAihubmixModelIds(null)).toEqual([])
})
})
describe("resolveBaseURL", () => {
const SERVER_BASE_URL = "https://server-proxy.example.com"
@@ -282,70 +256,6 @@ vi.mock("@ai-sdk/deepseek", () => {
return { createDeepSeek: mockCreateDeepSeek, deepseek: mockDeepseek }
})
vi.mock("@aihubmix/ai-sdk-provider", () => {
const mockModel = { modelId: "test-model" }
const mockProviderFn = vi.fn(() => mockModel)
const mockCreateAihubmix = vi.fn(() => mockProviderFn)
const mockAihubmix = vi.fn(() => mockModel)
return { aihubmix: mockAihubmix, createAihubmix: mockCreateAihubmix }
})
describe("AIHubMix provider", () => {
let createAihubmixMock: ReturnType<typeof vi.fn>
const savedEnv: Record<string, string | undefined> = {}
beforeEach(async () => {
savedEnv.AIHUBMIX_API_KEY = process.env.AIHUBMIX_API_KEY
savedEnv.AIHUBMIX_BASE_URL = process.env.AIHUBMIX_BASE_URL
delete process.env.AIHUBMIX_BASE_URL
const mod = await import("@aihubmix/ai-sdk-provider")
createAihubmixMock = mod.createAihubmix as ReturnType<typeof vi.fn>
createAihubmixMock.mockClear()
})
afterEach(() => {
process.env.AIHUBMIX_API_KEY = savedEnv.AIHUBMIX_API_KEY
process.env.AIHUBMIX_BASE_URL = savedEnv.AIHUBMIX_BASE_URL
})
it("uses AIHUBMIX_API_KEY for server configured AIHubMix", () => {
process.env.AIHUBMIX_API_KEY = "server-aihubmix-key"
getAIModel({
provider: "aihubmix",
modelId: "claude-sonnet-4-5-20250929",
})
expect(createAihubmixMock).toHaveBeenCalledWith({
apiKey: "server-aihubmix-key",
appCode: "MSBS9675",
})
})
it("uses client BYOK API key for AIHubMix", () => {
getAIModel({
provider: "aihubmix",
apiKey: "client-aihubmix-key",
modelId: "gpt-5.1",
})
expect(createAihubmixMock).toHaveBeenCalledWith({
apiKey: "client-aihubmix-key",
appCode: "MSBS9675",
})
})
it("recognizes AIHubMix standard endpoints", () => {
expect(isAihubmixStandardBaseURL(undefined)).toBe(true)
expect(isAihubmixStandardBaseURL("https://aihubmix.com")).toBe(true)
expect(isAihubmixStandardBaseURL("https://aihubmix.com/v1/")).toBe(true)
expect(isAihubmixStandardBaseURL("https://proxy.example.com/v1")).toBe(
false,
)
})
})
describe("Kimi provider uses createDeepSeek for reasoning_content support", () => {
let createDeepSeekMock: ReturnType<typeof vi.fn>
const savedEnv: Record<string, string | undefined> = {}

View File

@@ -159,44 +159,6 @@ describe("loadFlattenedServerModels", () => {
expect(defaultModel.modelId).toBe("gpt-4o") // First model of default provider
})
it("falls back to comma-separated AI_MODEL when no other config is set", async () => {
process.env.AI_MODELS_CONFIG = ""
process.env.AI_MODELS_CONFIG_PATH = `non-existent-config-${Date.now()}.json`
process.env.AI_PROVIDER = "openai"
process.env.AI_MODEL = "gpt-4o, gpt-4o-mini, gpt-4o"
const models = await loadFlattenedServerModels()
// Trims, deduplicates, and preserves order
expect(models.map((m) => m.modelId)).toEqual(["gpt-4o", "gpt-4o-mini"])
expect(models.every((m) => m.provider === "openai")).toBe(true)
// First model is marked default (provider has default: true)
const defaults = models.filter((m) => m.isDefault)
expect(defaults.length).toBe(1)
expect(defaults[0].modelId).toBe("gpt-4o")
})
it("does not synthesize when AI_MODEL has no comma", async () => {
process.env.AI_MODELS_CONFIG = ""
process.env.AI_MODELS_CONFIG_PATH = `non-existent-config-${Date.now()}.json`
process.env.AI_PROVIDER = "openai"
process.env.AI_MODEL = "gpt-4o"
const models = await loadFlattenedServerModels()
expect(models).toEqual([])
})
it("does not synthesize when AI_PROVIDER is unset", async () => {
process.env.AI_MODELS_CONFIG = ""
process.env.AI_MODELS_CONFIG_PATH = `non-existent-config-${Date.now()}.json`
delete process.env.AI_PROVIDER
process.env.AI_MODEL = "gpt-4o, gpt-4o-mini"
const models = await loadFlattenedServerModels()
expect(models).toEqual([])
})
it("preserves apiKeyEnv array in flattened models for load balancing", async () => {
const config: ServerModelsConfig = {
providers: [

View File

@@ -1,79 +1,21 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { describe, expect, it } from "vitest"
import { isPrivateUrl } from "@/lib/ssrf-protection"
// Mock DNS so tests are deterministic and never hit the network.
const lookupMock = vi.hoisted(() => vi.fn())
vi.mock("node:dns/promises", () => ({
default: { lookup: lookupMock },
lookup: lookupMock,
}))
describe("isPrivateUrl", () => {
beforeEach(() => {
lookupMock.mockReset()
it("blocks private IPv6 URLs", () => {
expect(isPrivateUrl("http://[::1]/")).toBe(true)
expect(isPrivateUrl("http://[0:0:0:0:0:0:0:1]/")).toBe(true)
expect(isPrivateUrl("http://[::]/")).toBe(true)
expect(isPrivateUrl("http://[::ffff:127.0.0.1]/")).toBe(true)
expect(isPrivateUrl("http://[fc00::1]/")).toBe(true)
expect(isPrivateUrl("http://[fd12:3456:789a::1]/")).toBe(true)
expect(isPrivateUrl("http://[fe80::1]/")).toBe(true)
expect(isPrivateUrl("http://[fe9f::1]/")).toBe(true)
expect(isPrivateUrl("http://[febf::1]/")).toBe(true)
})
it("blocks private IPv6 URLs (string-only fast path, no DNS)", async () => {
expect(await isPrivateUrl("http://[::1]/")).toBe(true)
expect(await isPrivateUrl("http://[0:0:0:0:0:0:0:1]/")).toBe(true)
expect(await isPrivateUrl("http://[::]/")).toBe(true)
expect(await isPrivateUrl("http://[::ffff:127.0.0.1]/")).toBe(true)
expect(await isPrivateUrl("http://[fc00::1]/")).toBe(true)
expect(await isPrivateUrl("http://[fd12:3456:789a::1]/")).toBe(true)
expect(await isPrivateUrl("http://[fe80::1]/")).toBe(true)
expect(await isPrivateUrl("http://[fe9f::1]/")).toBe(true)
expect(await isPrivateUrl("http://[febf::1]/")).toBe(true)
expect(lookupMock).not.toHaveBeenCalled()
})
it("blocks literal private IPv4 without DNS", async () => {
expect(await isPrivateUrl("http://127.0.0.1/")).toBe(true)
expect(await isPrivateUrl("http://10.0.0.5/")).toBe(true)
expect(await isPrivateUrl("http://192.168.1.1/")).toBe(true)
expect(await isPrivateUrl("http://169.254.169.254/")).toBe(true)
expect(await isPrivateUrl("http://0.0.0.0/")).toBe(true)
// 100.64.0.0/10 CGNAT (RFC 6598), routable in some cloud internal nets
expect(await isPrivateUrl("http://100.64.0.1/")).toBe(true)
expect(await isPrivateUrl("http://100.127.255.255/")).toBe(true)
expect(lookupMock).not.toHaveBeenCalled()
})
it("treats CGNAT boundaries correctly", async () => {
// 100.63.x and 100.128.x are outside 100.64.0.0/10 → public
lookupMock.mockResolvedValue([{ address: "100.63.255.255", family: 4 }])
expect(await isPrivateUrl("http://just-below.example/")).toBe(false)
lookupMock.mockResolvedValue([{ address: "100.128.0.1", family: 4 }])
expect(await isPrivateUrl("http://just-above.example/")).toBe(false)
})
it("blocks a hostname that resolves to a private IPv6 address", async () => {
lookupMock.mockResolvedValue([{ address: "fd00::1", family: 6 }])
expect(await isPrivateUrl("http://v6.example.com/")).toBe(true)
})
it("allows public URLs that resolve to public IPs", async () => {
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }])
expect(await isPrivateUrl("https://example.com/article")).toBe(false)
})
it("blocks public-looking hostnames that resolve to a private IP (DNS-rebinding-style bypass)", async () => {
// e.g. 127-0-0-1.sslip.io resolves to 127.0.0.1
lookupMock.mockResolvedValue([{ address: "127.0.0.1", family: 4 }])
expect(await isPrivateUrl("http://127-0-0-1.sslip.io/")).toBe(true)
})
it("blocks when any resolved address is private", async () => {
lookupMock.mockResolvedValue([
{ address: "93.184.216.34", family: 4 },
{ address: "10.1.2.3", family: 4 },
])
expect(await isPrivateUrl("http://mixed.example.com/")).toBe(true)
})
it("blocks when DNS resolution fails", async () => {
lookupMock.mockRejectedValue(new Error("ENOTFOUND"))
expect(await isPrivateUrl("http://does-not-resolve.example/")).toBe(
true,
)
it("allows public URLs", () => {
expect(isPrivateUrl("https://example.com/article")).toBe(false)
expect(isPrivateUrl("https://fc00.example.com/article")).toBe(false)
})
})