diff --git a/app/[lang]/admin/page.tsx b/app/[lang]/admin/page.tsx
index b794c5d..38f0cfc 100644
--- a/app/[lang]/admin/page.tsx
+++ b/app/[lang]/admin/page.tsx
@@ -11,7 +11,7 @@ import {
RotateCcw,
ShieldCheck,
} from "lucide-react"
-import { useCallback, useEffect, useMemo, useRef, useState } from "react"
+import { useCallback, useEffect, useState } from "react"
import { ProviderLogo } from "@/components/provider-logo"
import { Button } from "@/components/ui/button"
import {
@@ -31,17 +31,31 @@ import {
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import {
+ PROVIDER_SUBGROUPS,
SETTING_GROUPS,
- SETTINGS_REGISTRY,
+ SETTINGS_BY_GROUP,
type SettingDef,
- SUBGROUP_PROVIDERS,
} from "@/lib/admin/settings-registry"
import { getApiEndpoint } from "@/lib/base-path"
-import type { ProviderName } from "@/lib/types/model-config"
+import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
import { cn } from "@/lib/utils"
const SESSION_PASSWORD_KEY = "next-ai-draw-io-admin-password"
+async function fetchSettings(pw: string) {
+ const res = await fetch(getApiEndpoint("/api/admin/settings"), {
+ headers: { "x-admin-password": pw },
+ })
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}))
+ throw new Error(data.error || `Request failed (${res.status})`)
+ }
+ return res.json() as Promise<{
+ writable: boolean
+ settings: SettingState[]
+ }>
+}
+
type SecretValue = { isSet: true; hint: string }
interface SettingState {
@@ -56,6 +70,11 @@ function isSecretValue(v: unknown): v is SecretValue {
return typeof v === "object" && v !== null && "isSet" in v
}
+// Editable text of a saved setting; secrets have none (write-only)
+function savedTextOf(state: SettingState | undefined): string {
+ return state && !isSecretValue(state.value) ? (state.value ?? "") : ""
+}
+
function SourceChip({ source }: { source: "file" | "env" | "default" }) {
if (source === "default") return null
return (
@@ -104,9 +123,7 @@ function SettingField({
const isDirty = pendingValue !== undefined
const source = state?.source ?? "default"
- const savedText =
- state && !isSecretValue(state.value) ? (state.value ?? "") : ""
- const currentValue = isDirty ? (pendingValue ?? "") : savedText
+ const currentValue = isDirty ? (pendingValue ?? "") : savedTextOf(state)
const secretIsSet = state ? isSecretValue(state.value) : false
const secretHint =
@@ -296,7 +313,7 @@ function SettingField({
}
function ProviderSubgroup({
- name,
+ provider,
defs,
settings,
pending,
@@ -304,7 +321,7 @@ function ProviderSubgroup({
disabled,
onChange,
}: {
- name: string
+ provider: ProviderName
defs: SettingDef[]
settings: SettingsMap
pending: Record
@@ -330,12 +347,8 @@ function ProviderSubgroup({
)}
aria-hidden="true"
/>
- {SUBGROUP_PROVIDERS[name] && (
-
- )}
- {name}
+
+ {PROVIDER_INFO[provider].label}
{configured && (
@@ -372,32 +385,19 @@ export default function AdminPage() {
const [pending, setPending] = useState>({})
const [errors, setErrors] = useState>({})
const [saving, setSaving] = useState(false)
- const [saveMessage, setSaveMessage] = useState("")
- const [justSaved, setJustSaved] = useState(false)
+ const [saveMessage, setSaveMessage] = useState<{
+ ok: boolean
+ text: string
+ } | null>(null)
const [activeGroup, setActiveGroup] = useState(SETTING_GROUPS[0].id)
// Toggleable groups (quota, observability): off by default, on when
// any of their fields is already configured
const [enabledGroups, setEnabledGroups] = useState>(
{},
)
- const mainRef = useRef(null)
const dirtyCount = Object.keys(pending).length
- const fetchSettings = useCallback(async (pw: string) => {
- const res = await fetch(getApiEndpoint("/api/admin/settings"), {
- headers: { "x-admin-password": pw },
- })
- if (!res.ok) {
- const data = await res.json().catch(() => ({}))
- throw new Error(data.error || `Request failed (${res.status})`)
- }
- return res.json() as Promise<{
- writable: boolean
- settings: SettingState[]
- }>
- }, [])
-
const applyResponse = useCallback(
(data: { writable: boolean; settings: SettingState[] }) => {
setWritable(data.writable)
@@ -409,10 +409,8 @@ export default function AdminPage() {
const next = { ...prev }
for (const group of SETTING_GROUPS) {
if (!group.toggleable) continue
- const configured = SETTINGS_REGISTRY.some(
- (d) =>
- d.group === group.id &&
- map[d.key]?.source !== "default",
+ const configured = SETTINGS_BY_GROUP.get(group.id)?.some(
+ (d) => map[d.key]?.source !== "default",
)
if (configured) next[group.id] = true
}
@@ -439,7 +437,7 @@ export default function AdminPage() {
setAuthLoading(false)
}
},
- [fetchSettings, applyResponse],
+ [applyResponse],
)
// Restore session on mount
@@ -449,14 +447,15 @@ export default function AdminPage() {
}, [login])
// Warn before leaving with unsaved changes
+ const hasDirty = dirtyCount > 0
useEffect(() => {
- if (dirtyCount === 0) return
+ if (!hasDirty) return
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault()
}
window.addEventListener("beforeunload", handler)
return () => window.removeEventListener("beforeunload", handler)
- }, [dirtyCount])
+ }, [hasDirty])
// Highlight the section currently in view in the sidebar
useEffect(() => {
@@ -483,7 +482,7 @@ export default function AdminPage() {
const handleChange = useCallback(
(key: string, value: string | null) => {
- setSaveMessage("")
+ setSaveMessage(null)
setErrors((prev) => {
if (!(key in prev)) return prev
const next = { ...prev }
@@ -492,17 +491,13 @@ export default function AdminPage() {
})
setPending((prev) => {
const state = settings[key]
- const savedText =
- state && !isSecretValue(state.value)
- ? (state.value ?? "")
- : ""
// Typing back the saved value (or clearing an untouched field)
// removes it from the dirty set
const isRevert =
value !== null &&
state?.source === "file" &&
!isSecretValue(state?.value) &&
- value === savedText
+ value === savedTextOf(state)
const isNoop =
value === "" &&
(!state || state.source !== "file") &&
@@ -521,7 +516,7 @@ export default function AdminPage() {
const handleSave = useCallback(async () => {
if (!authedPassword || dirtyCount === 0) return
setSaving(true)
- setSaveMessage("")
+ setSaveMessage(null)
setErrors({})
try {
const res = await fetch(getApiEndpoint("/api/admin/settings"), {
@@ -539,38 +534,30 @@ export default function AdminPage() {
const firstKey = Object.keys(data.errors)[0]
document.getElementById(`setting-${firstKey}`)?.focus()
} else {
- setSaveMessage(data.error || "Save failed")
+ setSaveMessage({
+ ok: false,
+ text: data.error || "Save failed",
+ })
}
return
}
applyResponse(data)
setPending({})
- setSaveMessage("Settings saved. Changes apply immediately.")
- setJustSaved(true)
- setTimeout(() => {
- setJustSaved(false)
- setSaveMessage("")
- }, 4000)
+ setSaveMessage({
+ ok: true,
+ text: "Settings saved. Changes apply immediately.",
+ })
+ setTimeout(() => setSaveMessage(null), 4000)
} catch {
- setSaveMessage(
- "Save failed: network error. Check your connection and try again.",
- )
+ setSaveMessage({
+ ok: false,
+ text: "Save failed: network error. Check your connection and try again.",
+ })
} finally {
setSaving(false)
}
}, [authedPassword, pending, dirtyCount, applyResponse])
- const providerSubgroups = useMemo(() => {
- const map = new Map()
- for (const def of SETTINGS_REGISTRY) {
- if (def.group !== "providers" || !def.subgroup) continue
- const list = map.get(def.subgroup) ?? []
- list.push(def)
- map.set(def.subgroup, list)
- }
- return map
- }, [])
-
// ── Login screen ─────────────────────────────────────────────────
if (!authedPassword) {
return (
@@ -702,11 +689,9 @@ export default function AdminPage() {
-
+
{SETTING_GROUPS.map((group) => {
- const defs = SETTINGS_REGISTRY.filter(
- (d) => d.group === group.id,
- )
+ const defs = SETTINGS_BY_GROUP.get(group.id) ?? []
const groupOff =
group.toggleable && !enabledGroups[group.id]
const fieldsDisabled = !writable || !!groupOff
@@ -764,11 +749,11 @@ export default function AdminPage() {
)}
>
{group.id === "providers"
- ? [...providerSubgroups.entries()].map(
- ([name, subDefs]) => (
+ ? [...PROVIDER_SUBGROUPS.entries()].map(
+ ([provider, subDefs]) => (
- {saveMessage}
+ {saveMessage?.text ?? ""}
{(dirtyCount > 0 || saveMessage) && (
@@ -811,12 +796,12 @@ export default function AdminPage() {
- {justSaved && (
+ {saveMessage?.ok && (
0
? `${dirtyCount} unsaved ${dirtyCount === 1 ? "change" : "changes"}`
- : saveMessage}
+ : saveMessage?.text}
{dirtyCount > 0 && (
diff --git a/app/api/admin/settings/route.ts b/app/api/admin/settings/route.ts
index 9302583..71825fc 100644
--- a/app/api/admin/settings/route.ts
+++ b/app/api/admin/settings/route.ts
@@ -6,9 +6,19 @@ import {
loadSettings,
saveSettings,
} from "@/lib/admin/settings"
-import { SETTINGS_BY_KEY, type SettingDef } from "@/lib/admin/settings-registry"
+import {
+ SETTINGS_BY_KEY,
+ SETTINGS_REGISTRY,
+ type SettingDef,
+} from "@/lib/admin/settings-registry"
import { ServerModelsConfigSchema } from "@/lib/server-model-config"
+// Zod schemas for json-type settings (kept here, server-side only — the
+// registry is imported by the client and must stay free of server deps)
+const JSON_VALIDATORS: Record
= {
+ AI_MODELS_CONFIG: ServerModelsConfigSchema,
+}
+
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
@@ -43,17 +53,13 @@ function maskSecret(value: string): { isSet: true; hint: string } {
function serializeSettings() {
const fileValues = loadSettings()
- return [...SETTINGS_BY_KEY.values()].map((def) => {
+ return SETTINGS_REGISTRY.map((def) => {
const source = getValueSource(def.key)
const raw =
source === "file"
? fileValues[def.key]
: (getEnvFallback(def.key) ?? null)
-
- let value: unknown = raw
- if (def.type === "secret" && raw) {
- value = maskSecret(raw)
- }
+ const value = def.type === "secret" && raw ? maskSecret(raw) : raw
return { key: def.key, source, value }
})
}
@@ -94,10 +100,11 @@ function validateValue(def: SettingDef, value: string): string | null {
} catch {
return "Invalid JSON"
}
- if (def.key === "AI_MODELS_CONFIG") {
- const result = ServerModelsConfigSchema.safeParse(parsed)
+ const schema = JSON_VALIDATORS[def.key]
+ if (schema) {
+ const result = schema.safeParse(parsed)
if (!result.success) {
- return `Invalid model registry: ${result.error.issues[0]?.message ?? "schema mismatch"}`
+ return `Invalid value: ${result.error.issues[0]?.message ?? "schema mismatch"}`
}
}
return null
diff --git a/app/api/validate-model/route.ts b/app/api/validate-model/route.ts
index d996467..34534cb 100644
--- a/app/api/validate-model/route.ts
+++ b/app/api/validate-model/route.ts
@@ -51,7 +51,7 @@ export async function POST(req: Request) {
}
// SECURITY: Block SSRF attacks via custom baseUrl
- if (baseUrl && !allowPrivateUrls && isPrivateUrl(baseUrl)) {
+ if (baseUrl && !allowPrivateUrls() && isPrivateUrl(baseUrl)) {
return NextResponse.json(
{ valid: false, error: "Invalid base URL" },
{ status: 400 },
diff --git a/lib/admin/settings-registry.ts b/lib/admin/settings-registry.ts
index 16fb95f..af71b7f 100644
--- a/lib/admin/settings-registry.ts
+++ b/lib/admin/settings-registry.ts
@@ -6,6 +6,8 @@
// - NEXT_PUBLIC_* vars: baked into the client bundle at build time
// - ADMIN_PASSWORD / SETTINGS_FILE: bootstrap values, env-only to avoid lockout
+import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
+
export type SettingType =
| "string"
| "secret"
@@ -26,8 +28,9 @@ export interface SettingDef {
placeholder?: string
// Value is only picked up at process start (module-load readers)
restartRequired?: boolean
- // Collapsible subsection within a group (used for the per-provider lists)
- subgroup?: string
+ // Provider this setting belongs to; rendered inside that provider's
+ // collapsible in the Provider Credentials group
+ provider?: ProviderName
}
export interface SettingGroup {
@@ -81,159 +84,46 @@ export const SETTING_GROUPS: SettingGroup[] = [
},
]
-// Provider name for each subgroup in the providers group (for logos)
-export const SUBGROUP_PROVIDERS: Record = {
- "AWS Bedrock": "bedrock",
- OpenAI: "openai",
- Anthropic: "anthropic",
- Google: "google",
- "Vertex AI": "vertexai",
- "Azure OpenAI": "azure",
- Ollama: "ollama",
- OpenRouter: "openrouter",
- DeepSeek: "deepseek",
- SiliconFlow: "siliconflow",
- SGLang: "sglang",
- "Vercel AI Gateway": "gateway",
- Doubao: "doubao",
- ModelScope: "modelscope",
- GLM: "glm",
- Qwen: "qwen",
- Kimi: "kimi",
- Qiniu: "qiniu",
- MiniMax: "minimax",
- Novita: "novita",
-}
+const PROVIDER_OPTIONS = Object.keys(PROVIDER_INFO)
-const PROVIDER_OPTIONS = [
- "bedrock",
- "openai",
- "anthropic",
- "google",
- "vertexai",
- "azure",
- "ollama",
- "openrouter",
- "deepseek",
- "siliconflow",
- "sglang",
- "gateway",
- "edgeone",
- "doubao",
- "modelscope",
- "glm",
- "qwen",
- "kimi",
- "qiniu",
- "minimax",
- "novita",
-]
-
-// Simple API-key + base-URL providers, rendered as one subgroup each
-const SIMPLE_PROVIDERS: Array<{
- subgroup: string
- keyVar: string
- urlVar: string
- urlPlaceholder?: string
-}> = [
- {
- subgroup: "OpenRouter",
- keyVar: "OPENROUTER_API_KEY",
- urlVar: "OPENROUTER_BASE_URL",
- urlPlaceholder: "https://openrouter.ai/api/v1",
- },
- {
- subgroup: "DeepSeek",
- keyVar: "DEEPSEEK_API_KEY",
- urlVar: "DEEPSEEK_BASE_URL",
- urlPlaceholder: "https://api.deepseek.com/v1",
- },
- {
- subgroup: "SiliconFlow",
- keyVar: "SILICONFLOW_API_KEY",
- urlVar: "SILICONFLOW_BASE_URL",
- urlPlaceholder: "https://api.siliconflow.com/v1",
- },
- {
- subgroup: "SGLang",
- keyVar: "SGLANG_API_KEY",
- urlVar: "SGLANG_BASE_URL",
- urlPlaceholder: "http://127.0.0.1:8000/v1",
- },
- {
- subgroup: "Vercel AI Gateway",
- keyVar: "AI_GATEWAY_API_KEY",
- urlVar: "AI_GATEWAY_BASE_URL",
- urlPlaceholder: "https://ai-gateway.vercel.sh/v1/ai",
- },
- {
- subgroup: "Doubao",
- keyVar: "DOUBAO_API_KEY",
- urlVar: "DOUBAO_BASE_URL",
- urlPlaceholder: "https://ark.cn-beijing.volces.com/api/v3",
- },
- {
- subgroup: "ModelScope",
- keyVar: "MODELSCOPE_API_KEY",
- urlVar: "MODELSCOPE_BASE_URL",
- urlPlaceholder: "https://api-inference.modelscope.cn/v1",
- },
- {
- subgroup: "GLM",
- keyVar: "GLM_API_KEY",
- urlVar: "GLM_BASE_URL",
- urlPlaceholder: "https://open.bigmodel.cn/api/paas/v4",
- },
- {
- subgroup: "Qwen",
- keyVar: "QWEN_API_KEY",
- urlVar: "QWEN_BASE_URL",
- urlPlaceholder: "https://dashscope.aliyuncs.com/compatible-mode/v1",
- },
- {
- subgroup: "Kimi",
- keyVar: "KIMI_API_KEY",
- urlVar: "KIMI_BASE_URL",
- urlPlaceholder: "https://api.moonshot.cn/v1",
- },
- {
- subgroup: "Qiniu",
- keyVar: "QINIU_API_KEY",
- urlVar: "QINIU_BASE_URL",
- urlPlaceholder: "https://api.qnaigc.com/v1",
- },
- {
- subgroup: "MiniMax",
- keyVar: "MINIMAX_API_KEY",
- urlVar: "MINIMAX_BASE_URL",
- urlPlaceholder: "https://api.minimaxi.com/anthropic",
- },
- {
- subgroup: "Novita",
- keyVar: "NOVITA_API_KEY",
- urlVar: "NOVITA_BASE_URL",
- urlPlaceholder: "https://api.novita.ai/openai",
- },
+// Providers with just {PREFIX}_API_KEY + {PREFIX}_BASE_URL env vars.
+// Labels and URL placeholders come from PROVIDER_INFO.
+const SIMPLE_PROVIDERS: Array<{ provider: ProviderName; prefix?: string }> = [
+ { provider: "openrouter" },
+ { provider: "deepseek" },
+ { provider: "siliconflow" },
+ { provider: "sglang" },
+ { provider: "gateway", prefix: "AI_GATEWAY" },
+ { provider: "doubao" },
+ { provider: "modelscope" },
+ { provider: "glm" },
+ { provider: "qwen" },
+ { provider: "kimi" },
+ { provider: "qiniu" },
+ { provider: "minimax" },
+ { provider: "novita" },
]
function simpleProviderSettings(): SettingDef[] {
- return SIMPLE_PROVIDERS.flatMap((p) => [
- {
- key: p.keyVar,
- group: "providers",
- subgroup: p.subgroup,
- type: "secret" as const,
- label: "API Key",
- },
- {
- key: p.urlVar,
- group: "providers",
- subgroup: p.subgroup,
- type: "string" as const,
- label: "Base URL",
- placeholder: p.urlPlaceholder,
- },
- ])
+ return SIMPLE_PROVIDERS.flatMap(
+ ({ provider, prefix = provider.toUpperCase() }) => [
+ {
+ key: `${prefix}_API_KEY`,
+ group: "providers",
+ provider,
+ type: "secret" as const,
+ label: "API Key",
+ },
+ {
+ key: `${prefix}_BASE_URL`,
+ group: "providers",
+ provider,
+ type: "string" as const,
+ label: "Base URL",
+ placeholder: PROVIDER_INFO[provider].defaultBaseUrl,
+ },
+ ],
+ )
}
export const SETTINGS_REGISTRY: SettingDef[] = [
@@ -259,7 +149,7 @@ export const SETTINGS_REGISTRY: SettingDef[] = [
{
key: "AWS_REGION",
group: "providers",
- subgroup: "AWS Bedrock",
+ provider: "bedrock",
type: "string",
label: "AWS Region",
placeholder: "us-west-2",
@@ -267,21 +157,21 @@ export const SETTINGS_REGISTRY: SettingDef[] = [
{
key: "AWS_ACCESS_KEY_ID",
group: "providers",
- subgroup: "AWS Bedrock",
+ provider: "bedrock",
type: "secret",
label: "Access Key ID",
},
{
key: "AWS_SECRET_ACCESS_KEY",
group: "providers",
- subgroup: "AWS Bedrock",
+ provider: "bedrock",
type: "secret",
label: "Secret Access Key",
},
{
key: "BEDROCK_REASONING_BUDGET_TOKENS",
group: "providers",
- subgroup: "AWS Bedrock",
+ provider: "bedrock",
type: "number",
label: "Reasoning Budget Tokens",
description: "Claude extended-thinking budget (1024–64000).",
@@ -291,7 +181,7 @@ export const SETTINGS_REGISTRY: SettingDef[] = [
{
key: "BEDROCK_REASONING_EFFORT",
group: "providers",
- subgroup: "AWS Bedrock",
+ provider: "bedrock",
type: "enum",
label: "Reasoning Effort",
description: "For Nova models.",
@@ -302,23 +192,23 @@ export const SETTINGS_REGISTRY: SettingDef[] = [
{
key: "OPENAI_API_KEY",
group: "providers",
- subgroup: "OpenAI",
+ provider: "openai",
type: "secret",
label: "API Key",
},
{
key: "OPENAI_BASE_URL",
group: "providers",
- subgroup: "OpenAI",
+ provider: "openai",
type: "string",
label: "Base URL",
description: "Custom OpenAI-compatible endpoint.",
- placeholder: "https://api.openai.com/v1",
+ placeholder: PROVIDER_INFO.openai.defaultBaseUrl,
},
{
key: "OPENAI_REASONING_EFFORT",
group: "providers",
- subgroup: "OpenAI",
+ provider: "openai",
type: "enum",
label: "Reasoning Effort",
options: ["minimal", "low", "medium", "high"],
@@ -326,7 +216,7 @@ export const SETTINGS_REGISTRY: SettingDef[] = [
{
key: "OPENAI_REASONING_SUMMARY",
group: "providers",
- subgroup: "OpenAI",
+ provider: "openai",
type: "enum",
label: "Reasoning Summary",
options: ["none", "brief", "detailed"],
@@ -336,7 +226,7 @@ export const SETTINGS_REGISTRY: SettingDef[] = [
{
key: "ANTHROPIC_API_KEY",
group: "providers",
- subgroup: "Anthropic",
+ provider: "anthropic",
type: "secret",
label: "API Key",
description: "Sent as x-api-key header.",
@@ -344,7 +234,7 @@ export const SETTINGS_REGISTRY: SettingDef[] = [
{
key: "ANTHROPIC_AUTH_TOKEN",
group: "providers",
- subgroup: "Anthropic",
+ provider: "anthropic",
type: "secret",
label: "Auth Token",
description:
@@ -353,15 +243,15 @@ export const SETTINGS_REGISTRY: SettingDef[] = [
{
key: "ANTHROPIC_BASE_URL",
group: "providers",
- subgroup: "Anthropic",
+ provider: "anthropic",
type: "string",
label: "Base URL",
- placeholder: "https://api.anthropic.com/v1",
+ placeholder: PROVIDER_INFO.anthropic.defaultBaseUrl,
},
{
key: "ANTHROPIC_THINKING_TYPE",
group: "providers",
- subgroup: "Anthropic",
+ provider: "anthropic",
type: "enum",
label: "Extended Thinking",
options: ["enabled"],
@@ -369,7 +259,7 @@ export const SETTINGS_REGISTRY: SettingDef[] = [
{
key: "ANTHROPIC_THINKING_BUDGET_TOKENS",
group: "providers",
- subgroup: "Anthropic",
+ provider: "anthropic",
type: "number",
label: "Thinking Budget Tokens",
min: 1024,
@@ -380,22 +270,22 @@ export const SETTINGS_REGISTRY: SettingDef[] = [
{
key: "GOOGLE_GENERATIVE_AI_API_KEY",
group: "providers",
- subgroup: "Google",
+ provider: "google",
type: "secret",
label: "API Key",
},
{
key: "GOOGLE_BASE_URL",
group: "providers",
- subgroup: "Google",
+ provider: "google",
type: "string",
label: "Base URL",
- placeholder: "https://generativelanguage.googleapis.com/v1beta",
+ placeholder: PROVIDER_INFO.google.defaultBaseUrl,
},
{
key: "GOOGLE_THINKING_BUDGET",
group: "providers",
- subgroup: "Google",
+ provider: "google",
type: "number",
label: "Thinking Budget (Gemini 2.5)",
min: 1024,
@@ -404,7 +294,7 @@ export const SETTINGS_REGISTRY: SettingDef[] = [
{
key: "GOOGLE_THINKING_LEVEL",
group: "providers",
- subgroup: "Google",
+ provider: "google",
type: "enum",
label: "Thinking Level (Gemini 3)",
options: ["low", "high"],
@@ -412,7 +302,7 @@ export const SETTINGS_REGISTRY: SettingDef[] = [
{
key: "GOOGLE_REASONING_EFFORT",
group: "providers",
- subgroup: "Google",
+ provider: "google",
type: "enum",
label: "Reasoning Effort",
options: ["low", "medium", "high"],
@@ -420,7 +310,7 @@ export const SETTINGS_REGISTRY: SettingDef[] = [
{
key: "GOOGLE_CANDIDATE_COUNT",
group: "providers",
- subgroup: "Google",
+ provider: "google",
type: "number",
label: "Candidate Count",
min: 1,
@@ -429,7 +319,7 @@ export const SETTINGS_REGISTRY: SettingDef[] = [
{
key: "GOOGLE_TOP_K",
group: "providers",
- subgroup: "Google",
+ provider: "google",
type: "number",
label: "Top K",
min: 1,
@@ -438,7 +328,7 @@ export const SETTINGS_REGISTRY: SettingDef[] = [
{
key: "GOOGLE_TOP_P",
group: "providers",
- subgroup: "Google",
+ provider: "google",
type: "number",
label: "Top P",
min: 0,
@@ -449,7 +339,7 @@ export const SETTINGS_REGISTRY: SettingDef[] = [
{
key: "GOOGLE_VERTEX_API_KEY",
group: "providers",
- subgroup: "Vertex AI",
+ provider: "vertexai",
type: "secret",
label: "API Key",
description: "Express Mode API key.",
@@ -457,14 +347,14 @@ export const SETTINGS_REGISTRY: SettingDef[] = [
{
key: "GOOGLE_VERTEX_BASE_URL",
group: "providers",
- subgroup: "Vertex AI",
+ provider: "vertexai",
type: "string",
label: "Base URL",
},
{
key: "GOOGLE_VERTEX_THINKING_BUDGET",
group: "providers",
- subgroup: "Vertex AI",
+ provider: "vertexai",
type: "number",
label: "Thinking Budget (Gemini 2.5)",
min: 1024,
@@ -473,7 +363,7 @@ export const SETTINGS_REGISTRY: SettingDef[] = [
{
key: "GOOGLE_VERTEX_THINKING_LEVEL",
group: "providers",
- subgroup: "Vertex AI",
+ provider: "vertexai",
type: "enum",
label: "Thinking Level (Gemini 3)",
options: ["minimal", "low", "medium", "high"],
@@ -483,14 +373,14 @@ export const SETTINGS_REGISTRY: SettingDef[] = [
{
key: "AZURE_API_KEY",
group: "providers",
- subgroup: "Azure OpenAI",
+ provider: "azure",
type: "secret",
label: "API Key",
},
{
key: "AZURE_RESOURCE_NAME",
group: "providers",
- subgroup: "Azure OpenAI",
+ provider: "azure",
type: "string",
label: "Resource Name",
description: "Endpoint becomes https://{name}.openai.azure.com.",
@@ -498,7 +388,7 @@ export const SETTINGS_REGISTRY: SettingDef[] = [
{
key: "AZURE_BASE_URL",
group: "providers",
- subgroup: "Azure OpenAI",
+ provider: "azure",
type: "string",
label: "Base URL",
description: "Alternative to resource name; takes precedence.",
@@ -506,7 +396,7 @@ export const SETTINGS_REGISTRY: SettingDef[] = [
{
key: "AZURE_REASONING_EFFORT",
group: "providers",
- subgroup: "Azure OpenAI",
+ provider: "azure",
type: "enum",
label: "Reasoning Effort",
options: ["low", "medium", "high"],
@@ -514,7 +404,7 @@ export const SETTINGS_REGISTRY: SettingDef[] = [
{
key: "AZURE_REASONING_SUMMARY",
group: "providers",
- subgroup: "Azure OpenAI",
+ provider: "azure",
type: "enum",
label: "Reasoning Summary",
options: ["none", "brief", "detailed"],
@@ -524,15 +414,15 @@ export const SETTINGS_REGISTRY: SettingDef[] = [
{
key: "OLLAMA_BASE_URL",
group: "providers",
- subgroup: "Ollama",
+ provider: "ollama",
type: "string",
label: "Base URL",
- placeholder: "https://ollama.com/api",
+ placeholder: PROVIDER_INFO.ollama.defaultBaseUrl,
},
{
key: "OLLAMA_API_KEY",
group: "providers",
- subgroup: "Ollama",
+ provider: "ollama",
type: "secret",
label: "API Key",
description: "For Ollama Cloud or authenticated remote instances.",
@@ -540,7 +430,7 @@ export const SETTINGS_REGISTRY: SettingDef[] = [
{
key: "OLLAMA_ENABLE_THINKING",
group: "providers",
- subgroup: "Ollama",
+ provider: "ollama",
type: "boolean",
label: "Enable Thinking",
},
@@ -593,56 +483,6 @@ export const SETTINGS_REGISTRY: SettingDef[] = [
placeholder: "code1,code2",
},
- // ── Quota ────────────────────────────────────────────────────────
- {
- key: "DAILY_REQUEST_LIMIT",
- group: "quota",
- type: "number",
- label: "Daily Request Limit",
- description: "Per IP per day.",
- min: 1,
- },
- {
- key: "DAILY_TOKEN_LIMIT",
- group: "quota",
- type: "number",
- label: "Daily Token Limit",
- description: "Per IP per day.",
- min: 1,
- },
- {
- key: "TPM_LIMIT",
- group: "quota",
- type: "number",
- label: "Tokens Per Minute",
- min: 1,
- },
- {
- key: "DYNAMODB_QUOTA_TABLE",
- group: "quota",
- type: "string",
- label: "DynamoDB Table",
- description: "Quota enforcement is disabled when empty.",
- restartRequired: true,
- },
- {
- key: "DYNAMODB_REGION",
- group: "quota",
- type: "string",
- label: "DynamoDB Region",
- placeholder: "ap-northeast-1",
- restartRequired: true,
- },
- {
- key: "QUOTA_TIMEZONE",
- group: "quota",
- type: "string",
- label: "Quota Timezone",
- description: "Timezone for the daily reset boundary.",
- placeholder: "UTC",
- restartRequired: true,
- },
-
// ── Features ─────────────────────────────────────────────────────
{
key: "ENABLE_VLM_VALIDATION",
@@ -706,8 +546,74 @@ export const SETTINGS_REGISTRY: SettingDef[] = [
placeholder: "https://cloud.langfuse.com",
restartRequired: true,
},
+
+ // ── Quota ────────────────────────────────────────────────────────
+ {
+ key: "DAILY_REQUEST_LIMIT",
+ group: "quota",
+ type: "number",
+ label: "Daily Request Limit",
+ description: "Per IP per day.",
+ min: 1,
+ },
+ {
+ key: "DAILY_TOKEN_LIMIT",
+ group: "quota",
+ type: "number",
+ label: "Daily Token Limit",
+ description: "Per IP per day.",
+ min: 1,
+ },
+ {
+ key: "TPM_LIMIT",
+ group: "quota",
+ type: "number",
+ label: "Tokens Per Minute",
+ min: 1,
+ },
+ {
+ key: "DYNAMODB_QUOTA_TABLE",
+ group: "quota",
+ type: "string",
+ label: "DynamoDB Table",
+ description: "Quota enforcement is disabled when empty.",
+ restartRequired: true,
+ },
+ {
+ key: "DYNAMODB_REGION",
+ group: "quota",
+ type: "string",
+ label: "DynamoDB Region",
+ placeholder: "ap-northeast-1",
+ restartRequired: true,
+ },
+ {
+ key: "QUOTA_TIMEZONE",
+ group: "quota",
+ type: "string",
+ label: "Quota Timezone",
+ description: "Timezone for the daily reset boundary.",
+ placeholder: "UTC",
+ restartRequired: true,
+ },
]
export const SETTINGS_BY_KEY: Map = new Map(
SETTINGS_REGISTRY.map((def) => [def.key, def]),
)
+
+export const SETTINGS_BY_GROUP: Map = new Map(
+ SETTING_GROUPS.map((g) => [
+ g.id,
+ SETTINGS_REGISTRY.filter((d) => d.group === g.id),
+ ]),
+)
+
+// Provider Credentials group, keyed by provider in registry order
+export const PROVIDER_SUBGROUPS: Map = new Map()
+for (const def of SETTINGS_REGISTRY) {
+ if (!def.provider) continue
+ const list = PROVIDER_SUBGROUPS.get(def.provider) ?? []
+ list.push(def)
+ PROVIDER_SUBGROUPS.set(def.provider, list)
+}
diff --git a/lib/admin/settings.ts b/lib/admin/settings.ts
index ae081f9..89e30a7 100644
--- a/lib/admin/settings.ts
+++ b/lib/admin/settings.ts
@@ -62,22 +62,18 @@ export function applyToEnv(): void {
overlaidKeys = new Set(Object.keys(values))
}
-// Whether a key's current value comes from the file, the environment, or is unset
-export function getValueSource(key: string): "file" | "env" | "default" {
- const values = loadSettings()
- if (key in values) return "file"
- const envValue = overlaidKeys.has(key)
- ? originalEnv[key]
- : (process.env[key] ?? null)
- return envValue !== null && envValue !== undefined ? "env" : "default"
-}
-
// The effective env value if the file entry were removed (for fallback display)
export function getEnvFallback(key: string): string | null {
if (overlaidKeys.has(key)) return originalEnv[key] ?? null
return process.env[key] ?? null
}
+// Whether a key's current value comes from the file, the environment, or is unset
+export function getValueSource(key: string): "file" | "env" | "default" {
+ if (key in loadSettings()) return "file"
+ return getEnvFallback(key) !== null ? "env" : "default"
+}
+
export function saveSettings(updates: Record): void {
const current = { ...loadSettings() }
for (const [key, value] of Object.entries(updates)) {
diff --git a/lib/ssrf-protection.ts b/lib/ssrf-protection.ts
index 25e5bb2..30593a6 100644
--- a/lib/ssrf-protection.ts
+++ b/lib/ssrf-protection.ts
@@ -82,5 +82,8 @@ export function isPrivateUrl(urlString: string): boolean {
/**
* Whether private URLs are allowed (defaults to true)
* Set ALLOW_PRIVATE_URLS=false to block private URLs
+ * Read per call so admin-panel changes apply without restart
*/
-export const allowPrivateUrls = process.env.ALLOW_PRIVATE_URLS !== "false"
+export function allowPrivateUrls(): boolean {
+ return process.env.ALLOW_PRIVATE_URLS !== "false"
+}