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 && (

{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" +}