feat: add file-based admin settings panel at /admin (#866)

* 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.

* 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

* 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

* 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.

* 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

* 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).

* 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.

* 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.

* 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.

* 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

* 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.

* 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.

* 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.

* 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.

* 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.
This commit is contained in:
Dayuan Jiang
2026-06-15 00:40:35 +09:00
committed by GitHub
parent 54ff8d982c
commit 449e4c4e26
37 changed files with 4343 additions and 725 deletions

37
lib/admin/auth.ts Normal file
View File

@@ -0,0 +1,37 @@
import { timingSafeEqual } from "crypto"
// Shared auth for admin API routes: compares x-admin-password header
// against the ADMIN_PASSWORD env var. Unset password = panel disabled.
export function checkAdminAuth(req: Request): Response | null {
const password = process.env.ADMIN_PASSWORD
if (!password) {
return Response.json(
{
error: "Admin panel is disabled. Set the ADMIN_PASSWORD environment variable to enable it.",
},
{ status: 403 },
)
}
const provided = req.headers.get("x-admin-password") || ""
const a = Buffer.from(provided)
const b = Buffer.from(password)
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return Response.json(
{ error: "Invalid admin password" },
{ status: 401 },
)
}
return null
}
export interface MaskedSecret {
isSet: true
hint: string
}
export function maskSecret(value: string): MaskedSecret {
return {
isSet: true,
hint: value.length > 8 ? `${value.slice(-4)}` : "••••",
}
}

303
lib/admin/providers.ts Normal file
View File

@@ -0,0 +1,303 @@
import { z } from "zod"
import {
ProviderNameSchema,
type ServerModelsConfig,
} from "@/lib/server-model-config"
import {
FIXED_CRED_PROVIDERS,
PROVIDER_INFO,
type ProviderName,
} from "@/lib/types/model-config"
import { type MaskedSecret, maskSecret } from "./auth"
import { loadSettings } from "./settings"
// Admin-configured providers, mirroring the user ModelConfigDialog's data
// model but stored server-side (settings.json, ADMIN_PROVIDERS key).
//
// They COEXIST with an env-based AI_MODELS_CONFIG / ai-models.json:
// loadRawServerModelsConfig() merges the env baseline with the panel's
// providers at read time, so .env stays authoritative for its own entries.
// Panel credentials are written to ADMIN_-prefixed env vars (wired up via
// apiKeyEnv/baseUrlEnv) so they never shadow standard vars like
// OPENAI_API_KEY that env-based entries may rely on.
export const ADMIN_PROVIDERS_KEY = "ADMIN_PROVIDERS"
// A secret field in transit: plaintext string (new value) or an
// {isSet} marker meaning "keep the stored value".
const SecretInputSchema = z
.union([z.string(), z.object({ isSet: z.literal(true), hint: z.string() })])
.optional()
export const AdminProviderSchema = z.object({
id: z.string().min(1),
provider: ProviderNameSchema,
name: z.string().optional(),
apiKey: SecretInputSchema,
baseUrl: z.string().optional(),
awsAccessKeyId: SecretInputSchema,
awsSecretAccessKey: SecretInputSchema,
awsRegion: z.string().optional(),
vertexApiKey: SecretInputSchema,
models: z.array(z.string().min(1)),
isDefault: z.boolean().optional(),
})
export const AdminProvidersSchema = z.array(AdminProviderSchema)
// Stored shape: secrets are plain strings (never {isSet} markers, which
// only exist in transit). Used to validate ADMIN_PROVIDERS on load so a
// hand-edited/corrupted value can't slip a marker object past maskSecret.
const StoredAdminProviderSchema = AdminProviderSchema.extend({
apiKey: z.string().optional(),
awsAccessKeyId: z.string().optional(),
awsSecretAccessKey: z.string().optional(),
vertexApiKey: z.string().optional(),
})
export type AdminProviderInput = z.infer<typeof AdminProviderSchema>
// Stored form: secrets are plain strings
export interface StoredAdminProvider {
id: string
provider: ProviderName
name?: string
apiKey?: string
baseUrl?: string
awsAccessKeyId?: string
awsSecretAccessKey?: string
awsRegion?: string
vertexApiKey?: string
models: string[]
isDefault?: boolean
}
const SECRET_FIELDS = [
"apiKey",
"awsAccessKeyId",
"awsSecretAccessKey",
"vertexApiKey",
] as const
// ADMIN_-prefixed env var names for instance `index` (0-based) of a provider
function credEnvNames(
provider: ProviderName,
index: number,
): { key?: string; url?: string } {
if (FIXED_CRED_PROVIDERS.includes(provider) || provider === "edgeone") {
return {}
}
const prefix =
provider === "gateway" ? "AI_GATEWAY" : provider.toUpperCase()
const suffix = index === 0 ? "" : `_${index + 1}`
return {
key: `ADMIN_${prefix}_API_KEY${suffix}`,
url: `ADMIN_${prefix}_BASE_URL${suffix}`,
}
}
export function loadAdminProviders(): StoredAdminProvider[] {
const raw = loadSettings()[ADMIN_PROVIDERS_KEY]
if (!raw) return []
try {
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
// Validate each entry's shape — a malformed/hand-edited value must
// not reach runtime code that assumes provider/models exist.
return parsed.flatMap((entry) => {
const result = StoredAdminProviderSchema.safeParse(entry)
return result.success ? [result.data as StoredAdminProvider] : []
})
} catch {
console.error("[admin-providers] Failed to parse stored providers")
return []
}
}
export type MaskedAdminProvider = Omit<
StoredAdminProvider,
(typeof SECRET_FIELDS)[number]
> & {
apiKey?: MaskedSecret
awsAccessKeyId?: MaskedSecret
awsSecretAccessKey?: MaskedSecret
vertexApiKey?: MaskedSecret
}
export function maskAdminProviders(
list: StoredAdminProvider[],
): MaskedAdminProvider[] {
return list.map((p) => {
const masked: MaskedAdminProvider = { ...p } as MaskedAdminProvider
for (const field of SECRET_FIELDS) {
const value = p[field]
masked[field] = value ? maskSecret(value) : undefined
}
return masked
})
}
// Resolve {isSet} markers in incoming secrets against the stored list
export function mergeSecrets(
incoming: AdminProviderInput[],
stored: StoredAdminProvider[],
): StoredAdminProvider[] {
const storedById = new Map(stored.map((p) => [p.id, p]))
return incoming.map((p) => {
const prev = storedById.get(p.id)
const merged = { ...p } as StoredAdminProvider
for (const field of SECRET_FIELDS) {
const value = p[field]
if (typeof value === "string") {
merged[field] = value || undefined
} else if (value?.isSet) {
merged[field] = prev?.[field]
} else {
merged[field] = undefined
}
}
return merged
})
}
function displayName(p: StoredAdminProvider): string {
return p.name?.trim() || PROVIDER_INFO[p.provider].label
}
export function validateAdminProviders(
list: StoredAdminProvider[],
envConfig: ServerModelsConfig | null = null,
): string | null {
const envProviders = envConfig?.providers ?? []
for (const single of FIXED_CRED_PROVIDERS) {
if (list.filter((p) => p.provider === single).length > 1) {
return `Only one ${PROVIDER_INFO[single].label} provider is supported (its credentials use fixed environment variables).`
}
// Its credentials are global; a panel instance would silently
// override the credentials env-configured models rely on
if (
list.some((p) => p.provider === single) &&
envProviders.some((p) => p.provider === single)
) {
return `${PROVIDER_INFO[single].label} is already configured in AI_MODELS_CONFIG / ai-models.json and shares global credentials. Manage it via the environment configuration instead.`
}
}
const names = list.map((p) => displayName(p))
if (new Set(names).size !== names.length) {
return "Provider display names must be unique."
}
const envNames = new Set(envProviders.map((p) => p.name))
const clash = names.find((n) => envNames.has(n))
if (clash) {
return `"${clash}" is already defined in AI_MODELS_CONFIG / ai-models.json. Use a different display name.`
}
if (list.filter((p) => p.isDefault).length > 1) {
return "Only one provider can be the default."
}
return null
}
// The panel's contribution to the server models config, derived at read
// time and merged with the env baseline by loadRawServerModelsConfig().
export function adminProvidersToConfig(
list: StoredAdminProvider[],
): ServerModelsConfig {
const config: ServerModelsConfig = { providers: [] }
const indexByProvider = new Map<ProviderName, number>()
for (const p of list) {
const index = indexByProvider.get(p.provider) ?? 0
indexByProvider.set(p.provider, index + 1)
if (p.models.length === 0) continue
const env = credEnvNames(p.provider, index)
config.providers.push({
name: displayName(p),
provider: p.provider,
models: p.models,
...(env.key && p.apiKey ? { apiKeyEnv: env.key } : {}),
...(env.url && p.baseUrl ? { baseUrlEnv: env.url } : {}),
...(p.isDefault ? { default: true } : {}),
})
}
return config
}
// Settings updates derived from the provider list: credential env vars,
// the stored list itself, and AI_PROVIDER/AI_MODEL when a default is set.
// Keys derived from `previous` but absent now are set to null (removed,
// falling back to the environment).
export function deriveEnvUpdates(
list: StoredAdminProvider[],
previous: StoredAdminProvider[],
): Record<string, string | null> {
const updates: Record<string, string | null> = {}
// Clear everything the previous list owned, then overwrite below
for (const key of derivedEnvKeys(previous)) updates[key] = null
const indexByProvider = new Map<ProviderName, number>()
for (const p of list) {
const index = indexByProvider.get(p.provider) ?? 0
indexByProvider.set(p.provider, index + 1)
if (p.provider === "bedrock") {
if (p.awsAccessKeyId) updates.AWS_ACCESS_KEY_ID = p.awsAccessKeyId
if (p.awsSecretAccessKey)
updates.AWS_SECRET_ACCESS_KEY = p.awsSecretAccessKey
if (p.awsRegion) updates.AWS_REGION = p.awsRegion
} else if (p.provider === "vertexai") {
if (p.vertexApiKey) updates.GOOGLE_VERTEX_API_KEY = p.vertexApiKey
if (p.baseUrl) updates.GOOGLE_VERTEX_BASE_URL = p.baseUrl
} else if (p.provider === "ollama") {
if (p.apiKey) updates.OLLAMA_API_KEY = p.apiKey
if (p.baseUrl) updates.OLLAMA_BASE_URL = p.baseUrl
} else {
const env = credEnvNames(p.provider, index)
if (env.key && p.apiKey) updates[env.key] = p.apiKey
if (env.url && p.baseUrl) updates[env.url] = p.baseUrl
}
}
updates[ADMIN_PROVIDERS_KEY] = list.length > 0 ? JSON.stringify(list) : null
// The panel's default also becomes the server-wide default model;
// without one, the env-configured default applies.
const defaultEntry = list.find((p) => p.isDefault && p.models.length > 0)
if (defaultEntry) {
updates.AI_PROVIDER = defaultEntry.provider
updates.AI_MODEL = defaultEntry.models[0]
}
return updates
}
// Every settings key the panel may have written for a given list.
// AI_MODELS_CONFIG is included to clean up values written by older
// versions of the panel (it is no longer written).
function derivedEnvKeys(list: StoredAdminProvider[]): string[] {
const keys = new Set<string>([
"AI_MODELS_CONFIG",
"AI_PROVIDER",
"AI_MODEL",
])
const indexByProvider = new Map<ProviderName, number>()
for (const p of list) {
const index = indexByProvider.get(p.provider) ?? 0
indexByProvider.set(p.provider, index + 1)
if (p.provider === "bedrock") {
keys.add("AWS_ACCESS_KEY_ID")
keys.add("AWS_SECRET_ACCESS_KEY")
keys.add("AWS_REGION")
} else if (p.provider === "vertexai") {
keys.add("GOOGLE_VERTEX_API_KEY")
keys.add("GOOGLE_VERTEX_BASE_URL")
} else if (p.provider === "ollama") {
keys.add("OLLAMA_API_KEY")
keys.add("OLLAMA_BASE_URL")
} else {
const env = credEnvNames(p.provider, index)
if (env.key) keys.add(env.key)
if (env.url) keys.add(env.url)
}
}
return [...keys]
}

View File

@@ -0,0 +1,229 @@
// Declarative registry of the general env vars editable in the admin panel.
// Drives both server-side validation (app/api/admin/settings) and UI
// rendering (app/[lang]/admin). Keys are exactly the env var names.
//
// AI providers and models are managed separately in the panel's Models
// section (lib/admin/providers.ts), not here.
//
// Not listed here (and therefore rejected by the API):
// - NEXT_PUBLIC_* vars: baked into the client bundle at build time
// - ADMIN_PASSWORD / SETTINGS_FILE: bootstrap values, env-only to avoid lockout
// - Per-provider reasoning/thinking tuning vars: env-only (see env.example)
export type SettingType = "string" | "secret" | "number" | "boolean" | "enum"
export interface SettingDef {
key: string
group: string
type: SettingType
label: string
description?: string
options?: string[]
min?: number
max?: number
placeholder?: string
// Built-in default applied at runtime when the value is unset, so the UI
// can reflect actual behavior (e.g. ALLOW_PRIVATE_URLS defaults to "true").
default?: string
// Value is only picked up at process start (module-load readers)
restartRequired?: boolean
}
export interface SettingGroup {
id: string
title: string
description: string
// Optional sections gated by an on/off switch in the panel; fields are
// grayed out until enabled. Starts on when any field is already set.
toggleable?: boolean
}
export const SETTING_GROUPS: SettingGroup[] = [
{
id: "generation",
title: "Generation",
description: "Output parameters applied to all chat requests.",
},
{
id: "access",
title: "Access Control",
description: "Restrict who can use this deployment.",
},
{
id: "features",
title: "Features",
description: "Optional features and security toggles.",
},
{
id: "observability",
title: "Observability",
description: "Langfuse tracing for LLM calls.",
toggleable: true,
},
{
id: "quota",
title: "Quota & Rate Limits",
description:
"Per-IP usage limits. Enforcement requires a DynamoDB table.",
toggleable: true,
},
]
export const SETTINGS_REGISTRY: SettingDef[] = [
// ── Generation ───────────────────────────────────────────────────
{
key: "TEMPERATURE",
group: "generation",
type: "number",
label: "Temperature",
description:
"Leave unset for reasoning models that reject temperature.",
min: 0,
max: 2,
},
{
key: "MAX_OUTPUT_TOKENS",
group: "generation",
type: "number",
label: "Max Output Tokens",
min: 1,
},
// ── Access Control ───────────────────────────────────────────────
{
key: "ACCESS_CODE_LIST",
group: "access",
type: "string",
label: "Access Codes",
description:
"Comma-separated list. Users must enter one to chat. Empty = open access.",
placeholder: "code1,code2",
},
// ── Features ─────────────────────────────────────────────────────
{
key: "ENABLE_VLM_VALIDATION",
group: "features",
type: "boolean",
label: "VLM Diagram Validation",
description:
"Visually validate generated diagrams with a vision model.",
},
{
key: "VALIDATION_MODEL",
group: "features",
type: "string",
label: "Validation Model",
description: "Falls back to the default AI model when empty.",
},
{
key: "VALIDATION_TIMEOUT",
group: "features",
type: "number",
label: "Validation Timeout (ms)",
min: 1000,
},
{
key: "ENABLE_HISTORY_XML_REPLACE",
group: "features",
type: "boolean",
label: "History XML Compression",
description: "Replace old diagram XML in history with placeholders.",
},
{
key: "ALLOW_PRIVATE_URLS",
group: "features",
type: "boolean",
label: "Allow Private URLs",
description:
"Turn off to block requests to private IPs and internal hostnames (SSRF protection).",
// Unset means allowed at runtime (ssrf-protection: !== "false")
default: "true",
},
// ── Observability ────────────────────────────────────────────────
{
key: "LANGFUSE_PUBLIC_KEY",
group: "observability",
type: "string",
label: "Langfuse Public Key",
placeholder: "pk-lf-…",
restartRequired: true,
},
{
key: "LANGFUSE_SECRET_KEY",
group: "observability",
type: "secret",
label: "Langfuse Secret Key",
restartRequired: true,
},
{
key: "LANGFUSE_BASEURL",
group: "observability",
type: "string",
label: "Langfuse Base URL",
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<string, SettingDef> = new Map(
SETTINGS_REGISTRY.map((def) => [def.key, def]),
)
export const SETTINGS_BY_GROUP: Map<string, SettingDef[]> = new Map(
SETTING_GROUPS.map((g) => [
g.id,
SETTINGS_REGISTRY.filter((d) => d.group === g.id),
]),
)

134
lib/admin/settings.ts Normal file
View File

@@ -0,0 +1,134 @@
import fs from "fs"
import path from "path"
// File-based admin settings, overlaid onto process.env (dotenv-style).
// Precedence: settings file > env var > built-in default.
// Keys are exactly the env var names.
interface SettingsFile {
version: 1
values: Record<string, string>
}
// Original env values snapshotted before the first overlay, so removing a
// key from the settings file restores the env default. null = was unset.
const originalEnv: Record<string, string | null> = {}
// Keys currently overlaid, so we can restore ones removed from the file.
let overlaidKeys = new Set<string>()
let cachedSettings: Record<string, string> | null = null
export function getSettingsPath(): string {
const custom = process.env.SETTINGS_FILE
if (custom && custom.trim().length > 0) return custom
return path.join(process.cwd(), "data", "settings.json")
}
export function loadSettings(): Record<string, string> {
if (cachedSettings) return cachedSettings
try {
const raw = fs.readFileSync(getSettingsPath(), "utf8")
const parsed = JSON.parse(raw) as SettingsFile
// Keep only string values — a hand-edited or corrupted file could
// hold null/arrays/numbers that would otherwise be overlaid onto
// process.env and coerce to junk like "[object Object]".
const values: Record<string, string> = {}
const rawValues =
parsed &&
typeof parsed.values === "object" &&
parsed.values &&
!Array.isArray(parsed.values)
? parsed.values
: {}
for (const [key, value] of Object.entries(rawValues)) {
if (typeof value === "string") values[key] = value
}
cachedSettings = values
} catch (err: any) {
if (err?.code !== "ENOENT") {
console.error("[admin-settings] Failed to read settings file:", err)
}
cachedSettings = {}
}
return cachedSettings
}
export function applyToEnv(): void {
const values = loadSettings()
// Restore env for keys that were overlaid before but are now gone
for (const key of overlaidKeys) {
if (!(key in values)) {
const original = originalEnv[key]
if (original === null) delete process.env[key]
else process.env[key] = original
}
}
for (const [key, value] of Object.entries(values)) {
if (!(key in originalEnv)) {
originalEnv[key] = process.env[key] ?? null
}
process.env[key] = value
}
overlaidKeys = new Set(Object.keys(values))
}
// 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<string, string | null>): void {
const current = { ...loadSettings() }
for (const [key, value] of Object.entries(updates)) {
if (value === null) delete current[key]
else current[key] = value
}
const filePath = getSettingsPath()
fs.mkdirSync(path.dirname(filePath), { recursive: true })
const tmpPath = `${filePath}.tmp`
const data: SettingsFile = { version: 1, values: current }
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), { mode: 0o600 })
fs.renameSync(tmpPath, filePath)
cachedSettings = current
applyToEnv()
}
let writableCache: boolean | null = null
export function isSettingsWritable(): boolean {
if (writableCache !== null) return writableCache
try {
const dir = path.dirname(getSettingsPath())
fs.mkdirSync(dir, { recursive: true })
fs.accessSync(dir, fs.constants.W_OK)
writableCache = true
} catch {
writableCache = false
}
return writableCache
}
// Test-only: reset module state
export function _resetForTests(): void {
cachedSettings = null
writableCache = null
for (const key of overlaidKeys) {
const original = originalEnv[key]
if (original === null) delete process.env[key]
else if (original !== undefined) process.env[key] = original
}
overlaidKeys = new Set()
for (const key of Object.keys(originalEnv)) delete originalEnv[key]
}

View File

@@ -537,7 +537,7 @@ function buildProviderOptions(
}
// Map of provider to required environment variable
const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
export const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
bedrock: null, // AWS SDK auto-uses IAM role on AWS, or env vars locally
openai: "OPENAI_API_KEY",
anthropic: "ANTHROPIC_API_KEY",

View File

@@ -402,6 +402,152 @@
"showUnvalidatedModels": "Show unvalidated models",
"allModelsShown": "All models are shown (including unvalidated)",
"unvalidatedModelWarning": "This model has not been validated",
"serverDefaultModel": "Server default model"
"serverDefaultModel": "Server default model",
"showValue": "Show value",
"hideValue": "Hide value"
},
"admin": {
"title": "Admin Settings",
"loginPrompt": "Enter the admin password (the ADMIN_PASSWORD environment variable) to manage server settings.",
"password": "Password",
"signIn": "Sign In",
"signingIn": "Signing In…",
"loginFailed": "Login failed",
"precedence": "File overrides env · env overrides defaults",
"notWritable": "The settings file is not writable on this deployment (serverless platforms have no persistent disk). Settings are shown read-only — configure via environment variables instead.",
"settingGroups": "Setting groups",
"enabled": "Enabled",
"disabled": "Disabled",
"enableGroup": "Enable {group}",
"unsavedChanges": "Unsaved changes",
"saved": "Settings saved. Changes apply immediately.",
"saveFailed": "Save failed. Check your connection and try again.",
"invalidSettings": "Some settings are invalid.",
"discard": "Discard",
"saveChanges": "Save Changes",
"saving": "Saving…",
"sourceSaved": "Saved",
"sourceEnv": "Env",
"sourceSavedTitle": "Set in the admin settings file",
"sourceEnvTitle": "Set by an environment variable",
"restartRequired": "Restart Required",
"modified": "Modified",
"notSet": "Not set",
"savedReplace": "Saved ({hint}) — type to replace",
"showValue": "Show value",
"hideValue": "Hide value",
"removeValue": "Remove value",
"removeValueTitle": "Remove the stored value",
"resetToDefault": "Reset to default",
"models": "Models",
"modelsDescription": "Server-side providers and models available to all users — no personal API key needed. The default provider's first model is used when users don't pick one.",
"addProviderHint": "Add a provider to offer server-side models to all users.",
"selectProviderHint": "Select or add a provider to configure its credentials and models.",
"addProviderToOfferModels": "Add at least one model to expose this provider to users.",
"managedViaEnv": "(managed via env)",
"envReadOnly": "Defined in AI_MODELS_CONFIG / ai-models.json — read-only here. Edit the environment configuration to change it.",
"defaultModel": "Default Model",
"noModelsConfigured": "No models configured",
"modelCount": "{count} model",
"modelCountPlural": "{count} models",
"default": "Default",
"setAsDefault": "Set as default provider",
"defaultProvider": "Default provider",
"modelIdPlaceholder": "Model ID…",
"addModel": "Add model",
"suggested": "Suggested",
"test": "Test",
"testOk": "OK ({ms}ms)",
"testFailed": "Failed",
"removeModel": "Remove {model}",
"deleteProviderTitle": "Delete {name}?",
"deleteProviderDesc": "Its credentials and models will be removed from the server after you save.",
"cancel": "Cancel",
"delete": "Delete",
"groups": {
"generation": {
"title": "Generation",
"description": "Output parameters applied to all chat requests."
},
"access": {
"title": "Access Control",
"description": "Restrict who can use this deployment."
},
"features": {
"title": "Features",
"description": "Optional features and security toggles."
},
"observability": {
"title": "Observability",
"description": "Langfuse tracing for LLM calls."
},
"quota": {
"title": "Quota & Rate Limits",
"description": "Per-IP usage limits. Enforcement requires a DynamoDB table."
}
},
"settings": {
"TEMPERATURE": {
"label": "Temperature",
"description": "Leave unset for reasoning models that reject temperature."
},
"MAX_OUTPUT_TOKENS": {
"label": "Max Output Tokens"
},
"ACCESS_CODE_LIST": {
"label": "Access Codes",
"description": "Comma-separated list. Users must enter one to chat. Empty = open access."
},
"ENABLE_VLM_VALIDATION": {
"label": "VLM Diagram Validation",
"description": "Visually validate generated diagrams with a vision model."
},
"VALIDATION_MODEL": {
"label": "Validation Model",
"description": "Falls back to the default AI model when empty."
},
"VALIDATION_TIMEOUT": {
"label": "Validation Timeout (ms)"
},
"ENABLE_HISTORY_XML_REPLACE": {
"label": "History XML Compression",
"description": "Replace old diagram XML in history with placeholders."
},
"ALLOW_PRIVATE_URLS": {
"label": "Allow Private URLs",
"description": "Turn off to block requests to private IPs and internal hostnames (SSRF protection)."
},
"LANGFUSE_PUBLIC_KEY": {
"label": "Langfuse Public Key"
},
"LANGFUSE_SECRET_KEY": {
"label": "Langfuse Secret Key"
},
"LANGFUSE_BASEURL": {
"label": "Langfuse Base URL"
},
"DAILY_REQUEST_LIMIT": {
"label": "Daily Request Limit",
"description": "Per IP per day."
},
"DAILY_TOKEN_LIMIT": {
"label": "Daily Token Limit",
"description": "Per IP per day."
},
"TPM_LIMIT": {
"label": "Tokens Per Minute"
},
"DYNAMODB_QUOTA_TABLE": {
"label": "DynamoDB Table",
"description": "Quota enforcement is disabled when empty."
},
"DYNAMODB_REGION": {
"label": "DynamoDB Region"
},
"QUOTA_TIMEZONE": {
"label": "Quota Timezone",
"description": "Timezone for the daily reset boundary."
}
}
}
}

View File

@@ -356,7 +356,9 @@
"showUnvalidatedModels": "未検証のモデルを表示",
"allModelsShown": "すべてのモデルを表示(未検証を含む)",
"unvalidatedModelWarning": "このモデルは検証されていません",
"serverDefaultModel": "サーバーデフォルトモデル"
"serverDefaultModel": "サーバーデフォルトモデル",
"showValue": "値を表示",
"hideValue": "値を非表示"
},
"templates": {
"title": "マイテンプレート",
@@ -403,5 +405,149 @@
"importNoFile": "JSON ファイルを選択してください",
"importFailed": "インポートに失敗しました:{error}",
"importSuccess": "{imported} 件インポート、{skipped} 件の重複をスキップしました"
},
"admin": {
"title": "管理者設定",
"loginPrompt": "サーバー設定を管理するには、管理者パスワードADMIN_PASSWORD 環境変数)を入力してください。",
"password": "パスワード",
"signIn": "ログイン",
"signingIn": "ログイン中…",
"loginFailed": "ログインに失敗しました",
"precedence": "ファイルが環境変数を上書き · 環境変数がデフォルトを上書き",
"notWritable": "このデプロイ環境では設定ファイルに書き込めません(サーバーレス環境には永続ディスクがありません)。設定は読み取り専用で表示されます——代わりに環境変数で構成してください。",
"settingGroups": "設定グループ",
"enabled": "有効",
"disabled": "無効",
"enableGroup": "{group} を有効化",
"unsavedChanges": "未保存の変更があります",
"saved": "設定を保存しました。変更は即座に反映されます。",
"saveFailed": "保存に失敗しました。接続を確認して再試行してください。",
"invalidSettings": "一部の設定が無効です。",
"discard": "破棄",
"saveChanges": "変更を保存",
"saving": "保存中…",
"sourceSaved": "保存済み",
"sourceEnv": "環境変数",
"sourceSavedTitle": "管理者設定ファイルで設定",
"sourceEnvTitle": "環境変数で設定",
"restartRequired": "再起動が必要",
"modified": "変更済み",
"notSet": "未設定",
"savedReplace": "保存済み({hint})——入力して置き換え",
"showValue": "値を表示",
"hideValue": "値を非表示",
"removeValue": "値を削除",
"removeValueTitle": "保存された値を削除",
"resetToDefault": "デフォルトに戻す",
"models": "モデル",
"modelsDescription": "全ユーザーが利用できるサーバー側のプロバイダーとモデル——個人の API キーは不要です。ユーザーがモデルを選択しない場合、デフォルトプロバイダーの最初のモデルが使用されます。",
"addProviderHint": "プロバイダーを追加して、全ユーザーにサーバー側モデルを提供します。",
"selectProviderHint": "プロバイダーを選択または追加して、その資格情報とモデルを構成します。",
"addProviderToOfferModels": "ユーザーにこのプロバイダーを公開するには、モデルを少なくとも 1 つ追加してください。",
"managedViaEnv": "(環境変数で管理)",
"envReadOnly": "AI_MODELS_CONFIG / ai-models.json で定義——ここでは読み取り専用です。変更するには環境構成を編集してください。",
"defaultModel": "デフォルトモデル",
"noModelsConfigured": "モデルが構成されていません",
"modelCount": "{count} 個のモデル",
"modelCountPlural": "{count} 個のモデル",
"default": "デフォルト",
"setAsDefault": "デフォルトプロバイダーに設定",
"defaultProvider": "デフォルトプロバイダー",
"modelIdPlaceholder": "モデル ID…",
"addModel": "モデルを追加",
"suggested": "おすすめ",
"test": "テスト",
"testOk": "正常({ms}ms",
"testFailed": "失敗",
"removeModel": "{model} を削除",
"deleteProviderTitle": "{name} を削除しますか?",
"deleteProviderDesc": "保存後、その資格情報とモデルはサーバーから削除されます。",
"cancel": "キャンセル",
"delete": "削除",
"groups": {
"generation": {
"title": "生成",
"description": "すべてのチャットリクエストに適用される出力パラメーター。"
},
"access": {
"title": "アクセス制御",
"description": "このデプロイを使用できるユーザーを制限します。"
},
"features": {
"title": "機能",
"description": "オプション機能とセキュリティの切り替え。"
},
"observability": {
"title": "オブザーバビリティ",
"description": "LLM 呼び出しの Langfuse トレース。"
},
"quota": {
"title": "クォータとレート制限",
"description": "IP ごとの使用制限。強制には DynamoDB テーブルが必要です。"
}
},
"settings": {
"TEMPERATURE": {
"label": "温度",
"description": "温度を受け付けない推論モデルの場合は未設定のままにしてください。"
},
"MAX_OUTPUT_TOKENS": {
"label": "最大出力トークン数"
},
"ACCESS_CODE_LIST": {
"label": "アクセスコード",
"description": "カンマ区切りのリスト。チャットにはいずれかの入力が必要です。空 = オープンアクセス。"
},
"ENABLE_VLM_VALIDATION": {
"label": "VLM 図検証",
"description": "ビジョンモデルで生成された図を視覚的に検証します。"
},
"VALIDATION_MODEL": {
"label": "検証モデル",
"description": "空の場合はデフォルトの AI モデルにフォールバックします。"
},
"VALIDATION_TIMEOUT": {
"label": "検証タイムアウトms"
},
"ENABLE_HISTORY_XML_REPLACE": {
"label": "履歴 XML 圧縮",
"description": "履歴内の古い図 XML をプレースホルダーで置き換えます。"
},
"ALLOW_PRIVATE_URLS": {
"label": "プライベート URL を許可",
"description": "オフにすると、プライベート IP や内部ホスト名へのリクエストをブロックしますSSRF 保護)。"
},
"LANGFUSE_PUBLIC_KEY": {
"label": "Langfuse Public Key"
},
"LANGFUSE_SECRET_KEY": {
"label": "Langfuse Secret Key"
},
"LANGFUSE_BASEURL": {
"label": "Langfuse Base URL"
},
"DAILY_REQUEST_LIMIT": {
"label": "1 日あたりのリクエスト上限",
"description": "IP ごと 1 日あたり。"
},
"DAILY_TOKEN_LIMIT": {
"label": "1 日あたりのトークン上限",
"description": "IP ごと 1 日あたり。"
},
"TPM_LIMIT": {
"label": "1 分あたりのトークン数"
},
"DYNAMODB_QUOTA_TABLE": {
"label": "DynamoDB テーブル",
"description": "空の場合、クォータの強制は無効になります。"
},
"DYNAMODB_REGION": {
"label": "DynamoDB リージョン"
},
"QUOTA_TIMEZONE": {
"label": "クォータタイムゾーン",
"description": "1 日のリセット境界に使用するタイムゾーン。"
}
}
}
}

View File

@@ -402,6 +402,152 @@
"showUnvalidatedModels": "顯示未驗證的模型",
"allModelsShown": "顯示所有模型(包括未驗證的)",
"unvalidatedModelWarning": "此模型尚未驗證",
"serverDefaultModel": "伺服器預設模型"
"serverDefaultModel": "伺服器預設模型",
"showValue": "顯示值",
"hideValue": "隱藏值"
},
"admin": {
"title": "管理員設定",
"loginPrompt": "輸入管理員密碼(即 ADMIN_PASSWORD 環境變數)以管理伺服器設定。",
"password": "密碼",
"signIn": "登入",
"signingIn": "正在登入…",
"loginFailed": "登入失敗",
"precedence": "檔案覆蓋環境變數 · 環境變數覆蓋預設值",
"notWritable": "此部署環境下設定檔不可寫入(無伺服器平台沒有持久化磁碟)。設定以唯讀方式顯示——請改用環境變數進行設定。",
"settingGroups": "設定分組",
"enabled": "已啟用",
"disabled": "已停用",
"enableGroup": "啟用 {group}",
"unsavedChanges": "有未儲存的變更",
"saved": "設定已儲存,變更立即生效。",
"saveFailed": "儲存失敗。請檢查網路連線後重試。",
"invalidSettings": "部分設定無效。",
"discard": "捨棄",
"saveChanges": "儲存變更",
"saving": "正在儲存…",
"sourceSaved": "已儲存",
"sourceEnv": "環境變數",
"sourceSavedTitle": "在管理員設定檔中設定",
"sourceEnvTitle": "透過環境變數設定",
"restartRequired": "需要重新啟動",
"modified": "已修改",
"notSet": "未設定",
"savedReplace": "已儲存({hint})——輸入以取代",
"showValue": "顯示值",
"hideValue": "隱藏值",
"removeValue": "移除值",
"removeValueTitle": "移除已儲存的值",
"resetToDefault": "重設為預設",
"models": "模型",
"modelsDescription": "面向所有使用者的伺服器端 provider 與模型——無需個人 API 金鑰。當使用者未選擇模型時,使用預設 provider 的第一個模型。",
"addProviderHint": "新增一個 provider為所有使用者提供伺服器端模型。",
"selectProviderHint": "選擇或新增一個 provider 以設定其憑證和模型。",
"addProviderToOfferModels": "至少新增一個模型,才能向使用者開放此 provider。",
"managedViaEnv": "(透過環境變數管理)",
"envReadOnly": "在 AI_MODELS_CONFIG / ai-models.json 中定義——此處唯讀。請編輯環境設定以變更。",
"defaultModel": "預設模型",
"noModelsConfigured": "未設定模型",
"modelCount": "{count} 個模型",
"modelCountPlural": "{count} 個模型",
"default": "預設",
"setAsDefault": "設為預設 provider",
"defaultProvider": "預設 provider",
"modelIdPlaceholder": "模型 ID…",
"addModel": "新增模型",
"suggested": "推薦",
"test": "測試",
"testOk": "正常({ms} 毫秒)",
"testFailed": "失敗",
"removeModel": "移除 {model}",
"deleteProviderTitle": "刪除 {name}",
"deleteProviderDesc": "儲存後,其憑證和模型將從伺服器上移除。",
"cancel": "取消",
"delete": "刪除",
"groups": {
"generation": {
"title": "生成",
"description": "套用於所有聊天請求的輸出參數。"
},
"access": {
"title": "存取控制",
"description": "限制誰可以使用此部署。"
},
"features": {
"title": "功能",
"description": "選用功能和安全開關。"
},
"observability": {
"title": "可觀測性",
"description": "對 LLM 呼叫進行 Langfuse 追蹤。"
},
"quota": {
"title": "配額與速率限制",
"description": "按 IP 的用量限制。強制執行需要 DynamoDB 表。"
}
},
"settings": {
"TEMPERATURE": {
"label": "溫度",
"description": "對於拒絕溫度參數的推理模型,請留空。"
},
"MAX_OUTPUT_TOKENS": {
"label": "最大輸出 token 數"
},
"ACCESS_CODE_LIST": {
"label": "存取碼",
"description": "以逗號分隔的清單。使用者需輸入其中之一才能聊天。留空 = 開放存取。"
},
"ENABLE_VLM_VALIDATION": {
"label": "VLM 圖表驗證",
"description": "使用視覺模型對產生的圖表進行視覺化驗證。"
},
"VALIDATION_MODEL": {
"label": "驗證模型",
"description": "留空時回退到預設 AI 模型。"
},
"VALIDATION_TIMEOUT": {
"label": "驗證逾時(毫秒)"
},
"ENABLE_HISTORY_XML_REPLACE": {
"label": "歷史 XML 壓縮",
"description": "用占位符取代歷史記錄中的舊圖表 XML。"
},
"ALLOW_PRIVATE_URLS": {
"label": "允許私有 URL",
"description": "關閉以阻擋對私有 IP 和內部主機名的請求SSRF 防護)。"
},
"LANGFUSE_PUBLIC_KEY": {
"label": "Langfuse Public Key"
},
"LANGFUSE_SECRET_KEY": {
"label": "Langfuse Secret Key"
},
"LANGFUSE_BASEURL": {
"label": "Langfuse Base URL"
},
"DAILY_REQUEST_LIMIT": {
"label": "每日請求上限",
"description": "每個 IP 每天。"
},
"DAILY_TOKEN_LIMIT": {
"label": "每日 token 上限",
"description": "每個 IP 每天。"
},
"TPM_LIMIT": {
"label": "每分鐘 token 數"
},
"DYNAMODB_QUOTA_TABLE": {
"label": "DynamoDB 表",
"description": "留空時配額強制執行被停用。"
},
"DYNAMODB_REGION": {
"label": "DynamoDB 區域"
},
"QUOTA_TIMEZONE": {
"label": "配額時區",
"description": "每日重置邊界所用的時區。"
}
}
}
}

View File

@@ -402,6 +402,152 @@
"showUnvalidatedModels": "显示未验证的模型",
"allModelsShown": "显示所有模型(包括未验证的)",
"unvalidatedModelWarning": "此模型尚未验证",
"serverDefaultModel": "服务器默认模型"
"serverDefaultModel": "服务器默认模型",
"showValue": "显示值",
"hideValue": "隐藏值"
},
"admin": {
"title": "管理员设置",
"loginPrompt": "输入管理员密码(即 ADMIN_PASSWORD 环境变量)以管理服务器设置。",
"password": "密码",
"signIn": "登录",
"signingIn": "正在登录…",
"loginFailed": "登录失败",
"precedence": "文件覆盖环境变量 · 环境变量覆盖默认值",
"notWritable": "此部署环境下设置文件不可写(无服务器平台没有持久化磁盘)。设置以只读方式显示——请改用环境变量进行配置。",
"settingGroups": "设置分组",
"enabled": "已启用",
"disabled": "已禁用",
"enableGroup": "启用 {group}",
"unsavedChanges": "有未保存的更改",
"saved": "设置已保存,更改立即生效。",
"saveFailed": "保存失败。请检查网络连接后重试。",
"invalidSettings": "部分设置无效。",
"discard": "放弃",
"saveChanges": "保存更改",
"saving": "正在保存…",
"sourceSaved": "已保存",
"sourceEnv": "环境变量",
"sourceSavedTitle": "在管理员设置文件中设置",
"sourceEnvTitle": "通过环境变量设置",
"restartRequired": "需要重启",
"modified": "已修改",
"notSet": "未设置",
"savedReplace": "已保存({hint})——输入以替换",
"showValue": "显示值",
"hideValue": "隐藏值",
"removeValue": "移除值",
"removeValueTitle": "移除已保存的值",
"resetToDefault": "恢复默认",
"models": "模型",
"modelsDescription": "面向所有用户的服务端 provider 和模型——无需个人 API 密钥。当用户未选择模型时,使用默认 provider 的第一个模型。",
"addProviderHint": "添加一个 provider为所有用户提供服务端模型。",
"selectProviderHint": "选择或添加一个 provider 以配置其凭证和模型。",
"addProviderToOfferModels": "至少添加一个模型,才能向用户开放此 provider。",
"managedViaEnv": "(通过环境变量管理)",
"envReadOnly": "在 AI_MODELS_CONFIG / ai-models.json 中定义——此处只读。请编辑环境配置以更改。",
"defaultModel": "默认模型",
"noModelsConfigured": "未配置模型",
"modelCount": "{count} 个模型",
"modelCountPlural": "{count} 个模型",
"default": "默认",
"setAsDefault": "设为默认 provider",
"defaultProvider": "默认 provider",
"modelIdPlaceholder": "模型 ID…",
"addModel": "添加模型",
"suggested": "推荐",
"test": "测试",
"testOk": "正常({ms} 毫秒)",
"testFailed": "失败",
"removeModel": "移除 {model}",
"deleteProviderTitle": "删除 {name}",
"deleteProviderDesc": "保存后,其凭证和模型将从服务器上移除。",
"cancel": "取消",
"delete": "删除",
"groups": {
"generation": {
"title": "生成",
"description": "应用于所有聊天请求的输出参数。"
},
"access": {
"title": "访问控制",
"description": "限制谁可以使用此部署。"
},
"features": {
"title": "功能",
"description": "可选功能和安全开关。"
},
"observability": {
"title": "可观测性",
"description": "对 LLM 调用进行 Langfuse 追踪。"
},
"quota": {
"title": "配额与速率限制",
"description": "按 IP 的用量限制。强制执行需要 DynamoDB 表。"
}
},
"settings": {
"TEMPERATURE": {
"label": "温度",
"description": "对于拒绝温度参数的推理模型,请留空。"
},
"MAX_OUTPUT_TOKENS": {
"label": "最大输出 token 数"
},
"ACCESS_CODE_LIST": {
"label": "访问码",
"description": "以逗号分隔的列表。用户需输入其中之一才能聊天。留空 = 开放访问。"
},
"ENABLE_VLM_VALIDATION": {
"label": "VLM 图表验证",
"description": "使用视觉模型对生成的图表进行可视化验证。"
},
"VALIDATION_MODEL": {
"label": "验证模型",
"description": "留空时回退到默认 AI 模型。"
},
"VALIDATION_TIMEOUT": {
"label": "验证超时(毫秒)"
},
"ENABLE_HISTORY_XML_REPLACE": {
"label": "历史 XML 压缩",
"description": "用占位符替换历史记录中的旧图表 XML。"
},
"ALLOW_PRIVATE_URLS": {
"label": "允许私有 URL",
"description": "关闭以阻止对私有 IP 和内部主机名的请求SSRF 防护)。"
},
"LANGFUSE_PUBLIC_KEY": {
"label": "Langfuse Public Key"
},
"LANGFUSE_SECRET_KEY": {
"label": "Langfuse Secret Key"
},
"LANGFUSE_BASEURL": {
"label": "Langfuse Base URL"
},
"DAILY_REQUEST_LIMIT": {
"label": "每日请求上限",
"description": "每个 IP 每天。"
},
"DAILY_TOKEN_LIMIT": {
"label": "每日 token 上限",
"description": "每个 IP 每天。"
},
"TPM_LIMIT": {
"label": "每分钟 token 数"
},
"DYNAMODB_QUOTA_TABLE": {
"label": "DynamoDB 表",
"description": "留空时配额强制执行被禁用。"
},
"DYNAMODB_REGION": {
"label": "DynamoDB 区域"
},
"QUOTA_TIMEZONE": {
"label": "配额时区",
"description": "每日重置边界所用的时区。"
}
}
}
}

View File

@@ -62,7 +62,7 @@ function getConfigPath(): string {
return path.join(process.cwd(), "ai-models.json")
}
export async function loadRawServerModelsConfig(): Promise<ServerModelsConfig | null> {
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
if (envConfig && envConfig.trim().length > 0) {
@@ -96,6 +96,40 @@ export async function loadRawServerModelsConfig(): Promise<ServerModelsConfig |
}
}
export async function loadRawServerModelsConfig(): Promise<ServerModelsConfig | null> {
const envConfig = await loadEnvServerModelsConfig()
// Merge in providers managed via the admin panel (settings.json).
// Dynamic import to avoid a module-init cycle with lib/admin/providers.
let adminConfig: ServerModelsConfig | null = null
try {
const { adminProvidersToConfig, loadAdminProviders } = await import(
"./admin/providers"
)
const adminProviders = loadAdminProviders()
if (adminProviders.length > 0) {
adminConfig = adminProvidersToConfig(adminProviders)
}
} catch (err) {
console.error(
"[server-model-config] Failed to load admin providers:",
err,
)
}
if (!adminConfig || adminConfig.providers.length === 0) return envConfig
if (!envConfig) return adminConfig
// A panel default overrides an env default
const adminHasDefault = adminConfig.providers.some((p) => p.default)
const envProviders = adminHasDefault
? envConfig.providers.map((p) =>
p.default ? { ...p, default: undefined } : p,
)
: envConfig.providers
return { providers: [...envProviders, ...adminConfig.providers] }
}
export async function loadFlattenedServerModels(): Promise<
FlattenedServerModel[]
> {

View File

@@ -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"
}

View File

@@ -85,6 +85,15 @@ export interface FlattenedModel {
baseUrlEnv?: string
}
// Providers whose server credentials live in fixed env vars
// (AWS_ACCESS_KEY_ID, GOOGLE_VERTEX_API_KEY, OLLAMA_API_KEY) with no
// apiKeyEnv redirection support — their credentials are global
export const FIXED_CRED_PROVIDERS: ProviderName[] = [
"bedrock",
"vertexai",
"ollama",
]
// Map provider names to models.dev logo names
export const PROVIDER_LOGO_MAP: Record<string, string> = {
openai: "openai",