Compare commits

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

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

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

- openai: GPT-5.5 / 5.4 frontier; drop deprecated 5.0-5.2 family
- anthropic: add Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5 (new dateless
  pinned IDs); fix wrong date suffixes on Opus/Sonnet 4.5
- google / vertexai: adopt Gemini 3 family; drop Gemini 2.0 (shut down)
  and 1.5
- azure: GPT-5.x line + o3 / o4-mini; drop gpt-4-turbo / gpt-35-turbo
- bedrock: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5, Nova Premier /
  Nova 2 Lite, Llama 4 Maverick / Scout, Mistral Large 3, Pixtral
- openrouter: refreshed against live /api/v1/models
- deepseek: V4 Pro / Flash
- siliconflow / modelscope: DeepSeek V4, Qwen 3.x, drop bogus
  qwen3.5-plus
- gateway: verified against live Vercel AI Gateway endpoint
- doubao: Seed 2.0 / 1.8 / 1.6 in official dash-form IDs
- minimax: + M2.5
- novita: M3, GLM-5.1, Kimi-K2.6, DeepSeek V4
2026-06-09 21:11:02 +09:00
37 changed files with 4343 additions and 725 deletions
+3
View File
@@ -76,3 +76,6 @@ ai-models.json
# local backups
*.bak
.gstack/
# admin panel settings (contains secrets)
data/
+3
View File
@@ -61,6 +61,9 @@ COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Writable dir for admin panel settings (data/settings.json)
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
USER nextjs
EXPOSE 3000
+8
View File
@@ -43,6 +43,8 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
- [Deploy on Vercel](#deploy-on-vercel)
- [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers)
- [Multi-Provider Support](#multi-provider-support)
- [Server-Side Multi-Model Configuration](#server-side-multi-model-configuration)
- [Admin Panel](#admin-panel)
- [How It Works](#how-it-works)
- [Support \& Contact](#support--contact)
- [FAQ](#faq)
@@ -224,6 +226,12 @@ All providers except AWS Bedrock and OpenRouter support custom endpoints.
Administrators can configure multiple server-side models that are available to all users without requiring personal API keys. Configure via `AI_MODELS_CONFIG` environment variable (JSON string) or `ai-models.json` file.
### Admin Panel
Set the `ADMIN_PASSWORD` environment variable and visit `/admin` to manage server settings (models, access codes, features, observability, quota) from a web panel instead of hand-editing `.env`.
📖 **[Admin Panel Guide](./docs/en/admin-panel.md)** — setup, precedence rules, and notes.
**Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-5.1, Gemini 3 Pro, and DeepSeek V3.2/R1.
Note that the `claude` series has been trained on draw.io diagrams with cloud architecture logos like AWS, Azure, GCP. So if you want to create cloud architecture diagrams, this is the best choice.
+65
View File
@@ -0,0 +1,65 @@
import { getApiEndpoint } from "@/lib/base-path"
import type { ProviderName } from "@/lib/types/model-config"
export const SESSION_PASSWORD_KEY = "next-ai-draw-io-admin-password"
// ── Shared types ─────────────────────────────────────────────────────
export type SecretValue = { isSet: true; hint: string }
export function isSecretValue(v: unknown): v is SecretValue {
return typeof v === "object" && v !== null && "isSet" in v
}
export interface SettingState {
key: string
source: "file" | "env" | "default"
value: string | SecretValue | null
}
export type SettingsMap = Record<string, SettingState>
// Editable text of a saved setting; secrets have none (write-only)
export function savedTextOf(state: SettingState | undefined): string {
return state && !isSecretValue(state.value) ? (state.value ?? "") : ""
}
// Admin provider in client state. Secret fields hold either a masked
// marker (unchanged) or a plaintext string (new value).
export interface AdminProvider {
id: string
provider: ProviderName
name?: string
apiKey?: string | SecretValue
baseUrl?: string
awsAccessKeyId?: string | SecretValue
awsSecretAccessKey?: string | SecretValue
awsRegion?: string
vertexApiKey?: string | SecretValue
models: string[]
isDefault?: boolean
}
// Provider defined in AI_MODELS_CONFIG / ai-models.json — shown read-only
export interface EnvProvider {
name: string
provider: ProviderName
models: string[]
isDefault: boolean
}
export async function adminFetch(path: string, pw: string, init?: RequestInit) {
const res = await fetch(getApiEndpoint(path), {
...init,
headers: {
...init?.headers,
"x-admin-password": pw,
...(init?.body ? { "Content-Type": "application/json" } : {}),
},
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
throw new Error(data.error || `Request failed (${res.status})`)
}
return data
}
+609
View File
@@ -0,0 +1,609 @@
import {
AlertCircle,
Check,
Loader2,
Plus,
Star,
Trash2,
X,
Zap,
} from "lucide-react"
import { useState } from "react"
import { ProviderCredentialsFields } from "@/components/provider-credentials-fields"
import { ProviderLogo } from "@/components/provider-logo"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import {
FIXED_CRED_PROVIDERS,
PROVIDER_INFO,
type ProviderName,
SUGGESTED_MODELS,
} from "@/lib/types/model-config"
import { cn } from "@/lib/utils"
import {
type AdminProvider,
adminFetch,
type EnvProvider,
} from "./admin-shared"
import { SecretInput } from "./setting-field"
// ── Models section (mirrors the user ModelConfigDialog) ──────────────
function ProviderDetail({
provider,
disabled,
password,
onUpdate,
onDelete,
}: {
provider: AdminProvider
disabled: boolean
password: string
onUpdate: (patch: Partial<AdminProvider>) => void
onDelete: () => void
}) {
const dict = useDictionary()
const [modelInput, setModelInput] = useState("")
const [deleteOpen, setDeleteOpen] = useState(false)
const [testing, setTesting] = useState<string | null>(null)
const [testResults, setTestResults] = useState<
Record<string, { ok: boolean; message: string }>
>({})
const info = PROVIDER_INFO[provider.provider]
const suggestions = (SUGGESTED_MODELS[provider.provider] || []).filter(
(m) => !provider.models.includes(m),
)
const addModel = (modelId: string) => {
const trimmed = modelId.trim()
if (!trimmed || provider.models.includes(trimmed)) return
onUpdate({ models: [...provider.models, trimmed] })
setModelInput("")
}
const testModel = async (modelId: string) => {
setTesting(modelId)
try {
const data = await adminFetch("/api/admin/test-model", password, {
method: "POST",
body: JSON.stringify({ provider, modelId }),
})
setTestResults((prev) => ({
...prev,
[modelId]: data.valid
? {
ok: true,
message: formatMessage(dict.admin.testOk, {
ms: data.responseTime,
}),
}
: {
ok: false,
message: data.error || dict.admin.testFailed,
},
}))
} catch (err) {
setTestResults((prev) => ({
...prev,
[modelId]: {
ok: false,
message:
err instanceof Error
? err.message
: dict.admin.testFailed,
},
}))
} finally {
setTesting(null)
}
}
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<ProviderLogo
provider={provider.provider}
className="size-5"
/>
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold">{info.label}</h3>
<p className="text-xs text-muted-foreground">
{provider.models.length === 0
? dict.admin.noModelsConfigured
: formatMessage(
provider.models.length === 1
? dict.admin.modelCount
: dict.admin.modelCountPlural,
{ count: provider.models.length },
)}
</p>
</div>
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<Star
className={cn(
"h-3.5 w-3.5",
provider.isDefault &&
"fill-amber-400 text-amber-400",
)}
aria-hidden="true"
/>
{dict.admin.default}
<Switch
checked={!!provider.isDefault}
disabled={disabled}
aria-label={dict.admin.setAsDefault}
onCheckedChange={(checked) =>
onUpdate({ isDefault: checked })
}
/>
</label>
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="mr-1.5 h-4 w-4" aria-hidden="true" />
{dict.admin.delete}
</Button>
</div>
{/* Credentials (shared with the user ModelConfigDialog) */}
<ProviderCredentialsFields
provider={provider.provider}
name={provider.name}
baseUrl={provider.baseUrl}
awsRegion={provider.awsRegion}
disabled={disabled}
onChange={(field, value) => onUpdate({ [field]: value })}
renderSecret={({ field, id }) => (
// Bare id keeps the shared component's <Label htmlFor={id}>
// associated; only one ProviderDetail is mounted at a time.
<SecretInput
id={id}
keepOnEmpty
value={provider[field]}
disabled={disabled}
onChange={(v) => onUpdate({ [field]: v })}
/>
)}
/>
{/* Models */}
<div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<Label className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{dict.admin.models}
</Label>
<div className="flex items-center gap-1.5">
<Input
value={modelInput}
disabled={disabled}
placeholder={dict.admin.modelIdPlaceholder}
spellCheck={false}
className="h-8 w-48 font-mono text-xs"
onChange={(e) => setModelInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") addModel(modelInput)
}}
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
disabled={disabled || !modelInput.trim()}
aria-label={dict.admin.addModel}
onClick={() => addModel(modelInput)}
>
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
{suggestions.length > 0 && (
<Select
disabled={disabled}
onValueChange={(v) => addModel(v)}
>
<SelectTrigger className="h-8 w-28 text-xs">
{dict.admin.suggested}
</SelectTrigger>
<SelectContent className="max-h-72">
{suggestions.map((m) => (
<SelectItem
key={m}
value={m}
className="font-mono text-xs"
>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
<div className="overflow-hidden rounded-lg border">
{provider.models.length === 0 ? (
<p className="p-5 text-center text-sm text-muted-foreground">
{dict.admin.addProviderToOfferModels}
</p>
) : (
<ul className="divide-y">
{provider.models.map((modelId, index) => {
const result = testResults[modelId]
return (
<li
key={modelId}
className="flex items-center gap-2 px-3 py-2"
>
<span className="min-w-0 flex-1 truncate font-mono text-xs">
{modelId}
{provider.isDefault &&
index === 0 && (
<span className="ml-2 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase text-amber-600 dark:text-amber-400">
{
dict.admin
.defaultModel
}
</span>
)}
</span>
{result && (
<span
className={cn(
"flex items-center gap-1 text-xs",
result.ok
? "text-green-600 dark:text-green-400"
: "text-destructive",
)}
>
{result.ok ? (
<Check
className="h-3.5 w-3.5"
aria-hidden="true"
/>
) : (
<AlertCircle
className="h-3.5 w-3.5"
aria-hidden="true"
/>
)}
<span className="max-w-48 truncate">
{result.message}
</span>
</span>
)}
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
disabled={
disabled || testing !== null
}
onClick={() =>
void testModel(modelId)
}
>
{testing === modelId ? (
<Loader2
className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<Zap
className="h-3.5 w-3.5"
aria-hidden="true"
/>
)}
<span className="ml-1">
{dict.admin.test}
</span>
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={disabled}
aria-label={formatMessage(
dict.admin.removeModel,
{ model: modelId },
)}
onClick={() =>
onUpdate({
models: provider.models.filter(
(m) => m !== modelId,
),
})
}
>
<X
className="h-3.5 w-3.5"
aria-hidden="true"
/>
</Button>
</li>
)
})}
</ul>
)}
</div>
</div>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{formatMessage(dict.admin.deleteProviderTitle, {
name: provider.name || info.label,
})}
</AlertDialogTitle>
<AlertDialogDescription>
{dict.admin.deleteProviderDesc}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{dict.admin.cancel}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
setDeleteOpen(false)
onDelete()
}}
>
{dict.admin.delete}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
export function ModelsSection({
providers,
envProviders,
disabled,
password,
onChange,
}: {
providers: AdminProvider[]
envProviders: EnvProvider[]
disabled: boolean
password: string
onChange: (providers: AdminProvider[]) => void
}) {
const dict = useDictionary()
const [selectedId, setSelectedId] = useState<string | null>(
providers[0]?.id ?? null,
)
const selected = providers.find((p) => p.id === selectedId)
const selectedEnv = envProviders.find((p) => `env:${p.name}` === selectedId)
const addProvider = (provider: ProviderName) => {
const newProvider: AdminProvider = {
id: crypto.randomUUID(),
provider,
models: [],
isDefault: providers.length === 0,
}
onChange([...providers, newProvider])
setSelectedId(newProvider.id)
}
const updateProvider = (id: string, patch: Partial<AdminProvider>) => {
onChange(
providers.map((p) => {
if (p.id !== id) {
// Only one default at a time
return patch.isDefault ? { ...p, isDefault: false } : p
}
return { ...p, ...patch }
}),
)
}
const deleteProvider = (id: string) => {
const next = providers.filter((p) => p.id !== id)
onChange(next)
setSelectedId(next[0]?.id ?? null)
}
return (
<div className="flex min-h-72 flex-col sm:flex-row">
{/* Provider list */}
<div className="flex w-full shrink-0 flex-col border-b sm:w-52 sm:border-b-0 sm:border-r">
<div className="flex-1 space-y-1 p-2">
{providers.length === 0 && envProviders.length === 0 && (
<p className="px-2 py-6 text-center text-xs text-muted-foreground">
{dict.admin.addProviderHint}
</p>
)}
{envProviders.map((p) => (
<button
key={`env:${p.name}`}
type="button"
onClick={() => setSelectedId(`env:${p.name}`)}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
selectedId === `env:${p.name}` &&
"bg-muted font-medium",
)}
>
<ProviderLogo provider={p.provider} />
<span className="min-w-0 flex-1 truncate">
{p.name}
</span>
<span className="rounded bg-muted px-1 py-0.5 text-[10px] font-medium uppercase text-muted-foreground">
{dict.admin.sourceEnv}
</span>
{p.isDefault && (
<Star
className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400"
aria-label={dict.admin.defaultProvider}
/>
)}
</button>
))}
{providers.map((p) => (
<button
key={p.id}
type="button"
onClick={() => setSelectedId(p.id)}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
selectedId === p.id && "bg-muted font-medium",
)}
>
<ProviderLogo provider={p.provider} />
<span className="min-w-0 flex-1 truncate">
{p.name || PROVIDER_INFO[p.provider].label}
</span>
{p.isDefault && (
<Star
className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400"
aria-label={dict.admin.defaultProvider}
/>
)}
</button>
))}
</div>
<div className="border-t p-2">
<Select
disabled={disabled}
onValueChange={(v) => addProvider(v as ProviderName)}
>
<SelectTrigger className="w-full">
<Plus
className="mr-1 h-4 w-4 text-muted-foreground"
aria-hidden="true"
/>
{dict.modelConfig.addProvider}
</SelectTrigger>
<SelectContent className="max-h-72">
{(Object.keys(PROVIDER_INFO) as ProviderName[]).map(
(p) => {
// Global-credential providers already in
// the env config can't be added here —
// panel credentials would override theirs
const envBlocked =
FIXED_CRED_PROVIDERS.includes(p) &&
envProviders.some(
(e) => e.provider === p,
)
return (
<SelectItem
key={p}
value={p}
disabled={envBlocked}
>
<div className="flex items-center gap-2">
<ProviderLogo provider={p} />
{PROVIDER_INFO[p].label}
{envBlocked && (
<span className="text-xs text-muted-foreground">
{
dict.admin
.managedViaEnv
}
</span>
)}
</div>
</SelectItem>
)
},
)}
</SelectContent>
</Select>
</div>
</div>
{/* Detail */}
<div className="min-w-0 flex-1 p-4">
{selected ? (
<ProviderDetail
key={selected.id}
provider={selected}
disabled={disabled}
password={password}
onUpdate={(patch) => updateProvider(selected.id, patch)}
onDelete={() => deleteProvider(selected.id)}
/>
) : selectedEnv ? (
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<ProviderLogo
provider={selectedEnv.provider}
className="size-5"
/>
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold">
{selectedEnv.name}
</h3>
<p className="text-xs text-muted-foreground">
{dict.admin.envReadOnly}
</p>
</div>
</div>
<div className="overflow-hidden rounded-lg border">
<ul className="divide-y">
{selectedEnv.models.map((modelId, index) => (
<li
key={modelId}
className="flex items-center gap-2 px-3 py-2"
>
<span className="min-w-0 flex-1 truncate font-mono text-xs">
{modelId}
{selectedEnv.isDefault &&
index === 0 && (
<span className="ml-2 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase text-amber-600 dark:text-amber-400">
{
dict.admin
.defaultModel
}
</span>
)}
</span>
</li>
))}
</ul>
</div>
</div>
) : (
<p className="py-12 text-center text-sm text-muted-foreground">
{dict.admin.selectProviderHint}
</p>
)}
</div>
</div>
)
}
+610
View File
@@ -0,0 +1,610 @@
"use client"
import {
AlertTriangle,
Check,
Loader2,
LockKeyhole,
ShieldCheck,
} from "lucide-react"
import { useCallback, useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary"
import {
SETTING_GROUPS,
SETTINGS_BY_GROUP,
} from "@/lib/admin/settings-registry"
import { getApiEndpoint } from "@/lib/base-path"
import { formatMessage } from "@/lib/i18n/utils"
import { cn } from "@/lib/utils"
import {
type AdminProvider,
adminFetch,
type EnvProvider,
isSecretValue,
SESSION_PASSWORD_KEY,
type SettingState,
type SettingsMap,
savedTextOf,
} from "./admin-shared"
import { ModelsSection } from "./models-section"
import { SettingField } from "./setting-field"
// ── Page ─────────────────────────────────────────────────────────────
const NAV_GROUP_IDS = ["models", ...SETTING_GROUPS.map((g) => g.id)]
export default function AdminPage() {
const dict = useDictionary()
// Localized group title/description, keyed by group id
const groupText = (id: string) =>
(
dict.admin.groups as Record<
string,
{ title: string; description: string } | undefined
>
)[id]
const navItems = NAV_GROUP_IDS.map((id) => ({
id,
title:
id === "models" ? dict.admin.models : (groupText(id)?.title ?? id),
}))
const [password, setPassword] = useState("")
const [authedPassword, setAuthedPassword] = useState<string | null>(null)
const [authError, setAuthError] = useState("")
const [authLoading, setAuthLoading] = useState(false)
const [writable, setWritable] = useState(true)
// Models section state
const [providers, setProviders] = useState<AdminProvider[]>([])
const [envProviders, setEnvProviders] = useState<EnvProvider[]>([])
const [savedProviders, setSavedProviders] = useState<string>("[]")
const providersDirty = JSON.stringify(providers) !== savedProviders
// General settings state
const [settings, setSettings] = useState<SettingsMap>({})
const [pending, setPending] = useState<Record<string, string | null>>({})
const [errors, setErrors] = useState<Record<string, string>>({})
const [enabledGroups, setEnabledGroups] = useState<Record<string, boolean>>(
{},
)
const [saving, setSaving] = useState(false)
const [saveMessage, setSaveMessage] = useState<{
ok: boolean
text: string
} | null>(null)
const [activeGroup, setActiveGroup] = useState("models")
const dirtyCount = Object.keys(pending).length + (providersDirty ? 1 : 0)
const applySettingsResponse = useCallback(
(data: { writable: boolean; settings: SettingState[] }) => {
setWritable(data.writable)
const map: SettingsMap = {}
for (const s of data.settings) map[s.key] = s
setSettings(map)
// Seed each toggle once from whether the group has configured
// values; don't stomp a user's explicit toggle on later saves
setEnabledGroups((prev) => {
const next = { ...prev }
for (const group of SETTING_GROUPS) {
if (!group.toggleable || group.id in next) continue
next[group.id] = !!SETTINGS_BY_GROUP.get(group.id)?.some(
(d) => map[d.key]?.source !== "default",
)
}
return next
})
},
[],
)
const applyProvidersResponse = useCallback(
(data: {
providers: AdminProvider[]
envProviders?: EnvProvider[]
}) => {
setProviders(data.providers)
setSavedProviders(JSON.stringify(data.providers))
setEnvProviders(data.envProviders ?? [])
},
[],
)
const login = useCallback(
async (pw: string) => {
setAuthLoading(true)
setAuthError("")
try {
const [settingsData, providersData] = await Promise.all([
adminFetch("/api/admin/settings", pw),
adminFetch("/api/admin/providers", pw),
])
applySettingsResponse(settingsData)
applyProvidersResponse(providersData)
setAuthedPassword(pw)
sessionStorage.setItem(SESSION_PASSWORD_KEY, pw)
} catch (err) {
setAuthError(
err instanceof Error ? err.message : dict.admin.loginFailed,
)
} finally {
setAuthLoading(false)
}
},
[applySettingsResponse, applyProvidersResponse, dict],
)
// Restore session on mount
useEffect(() => {
const stored = sessionStorage.getItem(SESSION_PASSWORD_KEY)
if (stored) void login(stored)
}, [login])
// Warn before leaving with unsaved changes
const hasDirty = dirtyCount > 0
useEffect(() => {
if (!hasDirty) return
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault()
// Some browsers only show the prompt when returnValue is set
e.returnValue = ""
}
window.addEventListener("beforeunload", handler)
return () => window.removeEventListener("beforeunload", handler)
}, [hasDirty])
// Highlight the section currently in view in the sidebar
useEffect(() => {
if (!authedPassword) return
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort(
(a, b) =>
a.boundingClientRect.top - b.boundingClientRect.top,
)
if (visible[0]) setActiveGroup(visible[0].target.id)
},
{ rootMargin: "-10% 0px -50% 0px" },
)
for (const id of NAV_GROUP_IDS) {
const el = document.getElementById(id)
if (el) observer.observe(el)
}
return () => observer.disconnect()
}, [authedPassword])
const handleChange = useCallback(
(key: string, value: string | null) => {
setSaveMessage(null)
setErrors((prev) => {
if (!(key in prev)) return prev
const next = { ...prev }
delete next[key]
return next
})
setPending((prev) => {
const state = settings[key]
const isRevert =
value !== null &&
state?.source === "file" &&
!isSecretValue(state?.value) &&
value === savedTextOf(state)
const isNoop =
value === "" &&
(!state || state.source !== "file") &&
!isSecretValue(state?.value)
if (isRevert || isNoop) {
const next = { ...prev }
delete next[key]
return next
}
return { ...prev, [key]: value === "" ? null : value }
})
},
[settings],
)
// Toggling a group off stages deletion of its saved values so the
// feature actually turns off on save; toggling on drops those deletions.
const handleGroupToggle = useCallback(
(groupId: string, enabled: boolean) => {
setSaveMessage(null)
setEnabledGroups((prev) => ({ ...prev, [groupId]: enabled }))
const keys = (SETTINGS_BY_GROUP.get(groupId) ?? []).map(
(d) => d.key,
)
setPending((prev) => {
const next = { ...prev }
for (const key of keys) {
if (!enabled) {
// Stage deletion only for values currently set
if (settings[key]?.source !== "default")
next[key] = null
} else if (next[key] === null) {
delete next[key]
}
}
return next
})
},
[settings],
)
const handleSave = useCallback(async () => {
if (!authedPassword || dirtyCount === 0) return
setSaving(true)
setSaveMessage(null)
setErrors({})
try {
if (providersDirty) {
const data = await adminFetch(
"/api/admin/providers",
authedPassword,
{ method: "PUT", body: JSON.stringify({ providers }) },
)
applyProvidersResponse(data)
}
if (Object.keys(pending).length > 0) {
const res = await fetch(getApiEndpoint("/api/admin/settings"), {
method: "PUT",
headers: {
"Content-Type": "application/json",
"x-admin-password": authedPassword,
},
body: JSON.stringify({ values: pending }),
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
// Per-field validation errors come back as {errors: {...}}
if (data.errors) {
setErrors(data.errors)
const firstKey = Object.keys(data.errors)[0]
document.getElementById(`setting-${firstKey}`)?.focus()
throw new Error(dict.admin.invalidSettings)
}
throw new Error(
data.error || `Request failed (${res.status})`,
)
}
applySettingsResponse(data)
setPending({})
}
setSaveMessage({
ok: true,
text: dict.admin.saved,
})
setTimeout(() => setSaveMessage(null), 4000)
} catch (err) {
setSaveMessage({
ok: false,
text:
err instanceof Error ? err.message : dict.admin.saveFailed,
})
} finally {
setSaving(false)
}
}, [
authedPassword,
pending,
providers,
providersDirty,
dirtyCount,
applySettingsResponse,
applyProvidersResponse,
dict,
])
// ── Login screen ─────────────────────────────────────────────────
if (!authedPassword) {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<form
className="w-full max-w-sm space-y-4 rounded-lg border bg-card p-6 shadow-sm"
onSubmit={(e) => {
e.preventDefault()
void login(password)
}}
>
<div className="flex items-center gap-2">
<LockKeyhole
className="h-5 w-5 text-muted-foreground"
aria-hidden="true"
/>
<h1 className="text-lg font-semibold">
{dict.admin.title}
</h1>
</div>
<p className="text-sm text-muted-foreground">
{dict.admin.loginPrompt}
</p>
<div className="space-y-1.5">
<Label htmlFor="admin-password">
{dict.admin.password}
</Label>
<Input
id="admin-password"
name="admin-password"
type="password"
value={password}
autoComplete="current-password"
spellCheck={false}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<p
className={cn(
"text-sm text-destructive",
!authError && "sr-only",
)}
aria-live="polite"
>
{authError}
</p>
<Button
type="submit"
className="w-full"
disabled={authLoading}
>
{authLoading ? (
<>
<Loader2
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
{dict.admin.signingIn}
</>
) : (
dict.admin.signIn
)}
</Button>
</form>
</div>
)
}
// ── Settings screen ──────────────────────────────────────────────
return (
<div className="min-h-screen bg-background">
<header className="sticky top-0 z-20 border-b bg-background/95 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
<div className="flex items-center gap-2">
<ShieldCheck
className="h-5 w-5 text-primary"
aria-hidden="true"
/>
<h1 className="text-lg font-semibold">
{dict.admin.title}
</h1>
</div>
<p className="text-xs text-muted-foreground">
{dict.admin.precedence}
</p>
</div>
</header>
{!writable && (
<div className="border-b bg-amber-500/10">
<div className="mx-auto flex max-w-6xl items-center gap-2 px-4 py-3 text-sm text-amber-700 dark:text-amber-400">
<AlertTriangle
className="h-4 w-4 shrink-0"
aria-hidden="true"
/>
{dict.admin.notWritable}
</div>
</div>
)}
<div className="mx-auto flex max-w-6xl gap-8 px-4 py-6">
<nav
aria-label={dict.admin.settingGroups}
className="sticky top-20 hidden h-fit w-44 shrink-0 md:block"
>
<ul className="space-y-1">
{navItems.map((item) => (
<li key={item.id}>
<a
href={`#${item.id}`}
aria-current={
activeGroup === item.id
? "true"
: undefined
}
className={cn(
"block rounded-md px-3 py-1.5 text-sm hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
activeGroup === item.id
? "bg-muted font-medium text-foreground"
: "text-muted-foreground",
)}
>
{item.title}
</a>
</li>
))}
</ul>
</nav>
<main className="min-w-0 flex-1 pb-24">
{/* Models section */}
<section aria-labelledby="models" className="mb-10">
<h2
id="models"
className="scroll-mt-20 text-base font-semibold"
>
{dict.admin.models}
</h2>
<p className="mb-3 mt-1 text-sm text-muted-foreground text-pretty">
{dict.admin.modelsDescription}
</p>
<div className="overflow-hidden rounded-lg border bg-card">
<ModelsSection
providers={providers}
envProviders={envProviders}
disabled={!writable || saving}
password={authedPassword}
onChange={(next) => {
setSaveMessage(null)
setProviders(next)
}}
/>
</div>
</section>
{/* Registry-driven groups */}
{SETTING_GROUPS.map((group) => {
const defs = SETTINGS_BY_GROUP.get(group.id) ?? []
const groupOff =
group.toggleable && !enabledGroups[group.id]
const fieldsDisabled = !writable || saving || !!groupOff
const gt = groupText(group.id)
const title = gt?.title ?? group.title
return (
<section
key={group.id}
aria-labelledby={group.id}
className="mb-10"
>
<div className="flex items-center justify-between gap-4">
<h2
id={group.id}
className="scroll-mt-20 text-base font-semibold"
>
{title}
</h2>
{group.toggleable && (
<label
className={cn(
"flex cursor-pointer items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors motion-reduce:transition-none",
enabledGroups[group.id]
? "border-primary/30 bg-primary/5 text-primary"
: "border-border bg-muted/50 text-muted-foreground hover:border-foreground/30 hover:text-foreground",
)}
>
{enabledGroups[group.id]
? dict.admin.enabled
: dict.admin.disabled}
<Switch
checked={
!!enabledGroups[group.id]
}
disabled={!writable || saving}
aria-label={formatMessage(
dict.admin.enableGroup,
{ group: title },
)}
onCheckedChange={(checked) =>
handleGroupToggle(
group.id,
checked,
)
}
/>
</label>
)}
</div>
<p className="mb-3 mt-1 text-sm text-muted-foreground text-pretty">
{gt?.description ?? group.description}
</p>
<div
className={cn(
"rounded-lg border bg-card px-4",
groupOff &&
"pointer-events-none opacity-50",
)}
>
{defs.map((def) => (
<SettingField
key={def.key}
def={def}
state={settings[def.key]}
pendingValue={pending[def.key]}
error={errors[def.key]}
disabled={fieldsDisabled}
onChange={(v) =>
handleChange(def.key, v)
}
/>
))}
</div>
</section>
)
})}
</main>
</div>
{/* Always-mounted live region so save results are announced */}
<p aria-live="polite" className="sr-only">
{saveMessage?.text ?? ""}
</p>
{(dirtyCount > 0 || saveMessage) && (
<div className="fixed inset-x-0 bottom-0 z-30 border-t bg-background/95 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3">
<p
className={cn(
"flex min-w-0 items-center gap-1.5 truncate text-sm",
saveMessage?.ok
? "text-green-600 dark:text-green-400"
: saveMessage
? "text-destructive"
: "text-muted-foreground",
)}
>
{saveMessage?.ok && (
<Check
className="h-4 w-4 shrink-0"
aria-hidden="true"
/>
)}
{saveMessage && !saveMessage.ok
? saveMessage.text
: dirtyCount > 0
? dict.admin.unsavedChanges
: saveMessage?.text}
</p>
{dirtyCount > 0 && (
<div className="flex shrink-0 gap-2">
<Button
type="button"
variant="outline"
disabled={saving}
onClick={() => {
setPending({})
setErrors({})
setProviders(JSON.parse(savedProviders))
}}
>
{dict.admin.discard}
</Button>
<Button
type="button"
disabled={saving || !writable}
onClick={() => void handleSave()}
>
{saving ? (
<>
<Loader2
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
{dict.admin.saving}
</>
) : (
dict.admin.saveChanges
)}
</Button>
</div>
)}
</div>
</div>
)}
</div>
)
}
+312
View File
@@ -0,0 +1,312 @@
import { Eye, EyeOff, X } from "lucide-react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary"
import type { SettingDef } from "@/lib/admin/settings-registry"
import { formatMessage } from "@/lib/i18n/utils"
import { cn } from "@/lib/utils"
import {
isSecretValue,
type SecretValue,
type SettingState,
savedTextOf,
} from "./admin-shared"
// ── Small shared UI bits ─────────────────────────────────────────────
export function SourceChip({ source }: { source: "file" | "env" | "default" }) {
const dict = useDictionary()
if (source === "default") return null
return (
<span
className={cn(
"rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide",
source === "file"
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground",
)}
title={
source === "file"
? dict.admin.sourceSavedTitle
: dict.admin.sourceEnvTitle
}
>
{source === "file" ? dict.admin.sourceSaved : dict.admin.sourceEnv}
</span>
)
}
export function RestartBadge() {
const dict = useDictionary()
return (
<span className="rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-600 dark:text-amber-400">
{dict.admin.restartRequired}
</span>
)
}
// Secret input: shows masked hint as placeholder, typing replaces.
// With keepOnEmpty, clearing the field reverts to the stored value
// ("keep") instead of deleting it — explicit deletion is via the X button.
export function SecretInput({
id,
value,
disabled,
keepOnEmpty,
onChange,
}: {
id: string
value: string | SecretValue | undefined
disabled?: boolean
keepOnEmpty?: boolean
onChange: (value: string | SecretValue) => void
}) {
const dict = useDictionary()
const [show, setShow] = useState(false)
// The stored marker as it was at mount, to revert to on empty
const [original] = useState(value)
const hadStored = isSecretValue(original)
const text = typeof value === "string" ? value : ""
const placeholder = isSecretValue(value)
? formatMessage(dict.admin.savedReplace, { hint: value.hint })
: dict.admin.notSet
const handleText = (t: string) => {
if (t === "" && keepOnEmpty && hadStored && original) {
onChange(original)
} else {
onChange(t)
}
}
return (
<div className="flex items-center gap-1">
<Input
id={id}
type={show ? "text" : "password"}
value={text}
disabled={disabled}
spellCheck={false}
autoComplete="off"
placeholder={placeholder}
className="h-9 font-mono text-xs"
onChange={(e) => handleText(e.target.value)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
aria-label={show ? dict.admin.hideValue : dict.admin.showValue}
onClick={() => setShow((s) => !s)}
>
{show ? (
<EyeOff className="h-4 w-4" aria-hidden="true" />
) : (
<Eye className="h-4 w-4" aria-hidden="true" />
)}
</Button>
{keepOnEmpty && (hadStored || text) && !disabled && (
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
aria-label={dict.admin.removeValue}
title={dict.admin.removeValueTitle}
onClick={() => onChange("")}
>
<X className="h-4 w-4" aria-hidden="true" />
</Button>
)}
</div>
)
}
// ── General settings field (registry-driven) ─────────────────────────
export function SettingField({
def,
state,
pendingValue,
error,
disabled,
onChange,
}: {
def: SettingDef
state: SettingState | undefined
pendingValue: string | null | undefined
error?: string
disabled: boolean
onChange: (value: string | null) => void
}) {
const dict = useDictionary()
const isDirty = pendingValue !== undefined
const source = state?.source ?? "default"
const currentValue = isDirty ? (pendingValue ?? "") : savedTextOf(state)
const secretState = state && isSecretValue(state.value) ? state.value : null
// Localized label/description keyed by env var name, falling back to the
// registry's English (the registry stays canonical for the server).
const t = (
dict.admin.settings as Record<
string,
{ label?: string; description?: string } | undefined
>
)[def.key]
const label = t?.label ?? def.label
const description = t?.description ?? def.description
const inputId = `setting-${def.key}`
const errorId = `${inputId}-error`
let control: React.ReactNode
switch (def.type) {
case "boolean": {
// When unset, reflect the built-in runtime default so the toggle
// matches actual behavior (e.g. ALLOW_PRIVATE_URLS defaults on).
const effective =
currentValue !== "" ? currentValue : (def.default ?? "false")
// A saved boolean can be cleared back to its env/default value.
const canClear =
(isDirty && pendingValue !== null) || source === "file"
control = (
<div className="flex items-center gap-3">
<Switch
id={inputId}
checked={effective === "true"}
disabled={disabled}
onCheckedChange={(checked) =>
onChange(checked ? "true" : "false")
}
/>
{canClear && !disabled && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground"
onClick={() => onChange(null)}
>
{dict.admin.resetToDefault}
</Button>
)}
</div>
)
break
}
case "enum":
control = (
<Select
value={currentValue || undefined}
disabled={disabled}
onValueChange={onChange}
>
<SelectTrigger id={inputId} className="w-full max-w-xs">
<SelectValue placeholder={dict.admin.notSet} />
</SelectTrigger>
<SelectContent>
{def.options?.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
)
break
case "secret":
control = (
<div className="w-full max-w-md">
<SecretInput
id={inputId}
value={
isDirty
? (pendingValue ?? "")
: (secretState ?? currentValue)
}
disabled={disabled}
onChange={(v) =>
onChange(typeof v === "string" ? v : "")
}
/>
</div>
)
break
case "number":
control = (
<Input
id={inputId}
type="number"
inputMode="numeric"
min={def.min}
max={def.max}
value={currentValue}
disabled={disabled}
placeholder={def.placeholder ?? dict.admin.notSet}
className="w-full max-w-xs tabular-nums"
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
onChange={(e) => onChange(e.target.value)}
/>
)
break
default:
control = (
<Input
id={inputId}
type="text"
value={currentValue}
disabled={disabled}
spellCheck={false}
autoComplete="off"
placeholder={def.placeholder ?? dict.admin.notSet}
className="w-full max-w-md"
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
onChange={(e) => onChange(e.target.value)}
/>
)
}
return (
<div className="border-b border-border/60 py-4 last:border-b-0">
<div className="mb-1.5 flex flex-wrap items-center gap-2">
<Label htmlFor={inputId} className="text-sm font-medium">
{label}
</Label>
<SourceChip source={source} />
{def.restartRequired && <RestartBadge />}
{isDirty && (
<span className="rounded bg-blue-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-blue-600 dark:text-blue-400">
{dict.admin.modified}
</span>
)}
</div>
{description && (
<p className="mb-2 max-w-prose text-xs text-muted-foreground">
{description}
</p>
)}
{control}
<p
id={errorId}
className={cn(
"text-xs text-destructive",
error ? "mt-1.5" : "sr-only",
)}
aria-live="polite"
>
{error ?? ""}
</p>
</div>
)
}
+89
View File
@@ -0,0 +1,89 @@
import { checkAdminAuth } from "@/lib/admin/auth"
import {
AdminProvidersSchema,
deriveEnvUpdates,
loadAdminProviders,
maskAdminProviders,
mergeSecrets,
validateAdminProviders,
} from "@/lib/admin/providers"
import { isSettingsWritable, saveSettings } from "@/lib/admin/settings"
import { loadEnvServerModelsConfig } from "@/lib/server-model-config"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
async function payload() {
// Env-based providers (AI_MODELS_CONFIG / ai-models.json) are shown
// read-only in the panel; their credentials live in the environment
const envConfig = await loadEnvServerModelsConfig()
const adminProviders = loadAdminProviders()
// A panel default overrides any env default (matches the merge in
// loadRawServerModelsConfig), so env stars must reflect that
const adminHasDefault = adminProviders.some(
(p) => p.isDefault && p.models.length > 0,
)
return {
writable: isSettingsWritable(),
providers: maskAdminProviders(adminProviders),
envProviders:
envConfig?.providers.map((p) => ({
name: p.name,
provider: p.provider,
models: p.models,
isDefault: !!p.default && !adminHasDefault,
})) ?? [],
}
}
export async function GET(req: Request) {
const authError = checkAdminAuth(req)
if (authError) return authError
return Response.json(await payload())
}
export async function PUT(req: Request) {
const authError = checkAdminAuth(req)
if (authError) return authError
if (!isSettingsWritable()) {
return Response.json(
{
error: "Settings file is not writable on this deployment. Configure via environment variables instead.",
},
{ status: 503 },
)
}
let body: unknown
try {
body = await req.json()
} catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
}
const parsed = AdminProvidersSchema.safeParse(
(body as { providers?: unknown })?.providers,
)
if (!parsed.success) {
return Response.json(
{
error: `Invalid providers: ${parsed.error.issues[0]?.message ?? "schema mismatch"}`,
},
{ status: 400 },
)
}
const stored = loadAdminProviders()
const merged = mergeSecrets(parsed.data, stored)
const envConfig = await loadEnvServerModelsConfig()
const validationError = validateAdminProviders(merged, envConfig)
if (validationError) {
return Response.json({ error: validationError }, { status: 400 })
}
saveSettings(deriveEnvUpdates(merged, stored))
return Response.json(await payload())
}
+126
View File
@@ -0,0 +1,126 @@
import { checkAdminAuth, maskSecret } from "@/lib/admin/auth"
import {
getEnvFallback,
getValueSource,
isSettingsWritable,
loadSettings,
saveSettings,
} from "@/lib/admin/settings"
import {
SETTINGS_BY_KEY,
SETTINGS_REGISTRY,
type SettingDef,
} from "@/lib/admin/settings-registry"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
function serializeSettings() {
const fileValues = loadSettings()
return SETTINGS_REGISTRY.map((def) => {
const source = getValueSource(def.key)
const raw =
source === "file"
? fileValues[def.key]
: (getEnvFallback(def.key) ?? null)
const value = def.type === "secret" && raw ? maskSecret(raw) : raw
return { key: def.key, source, value }
})
}
export async function GET(req: Request) {
const authError = checkAdminAuth(req)
if (authError) return authError
return Response.json({
writable: isSettingsWritable(),
settings: serializeSettings(),
})
}
function validateValue(def: SettingDef, value: string): string | null {
switch (def.type) {
case "number": {
const num = Number(value)
if (!Number.isFinite(num)) return "Must be a number"
if (def.min !== undefined && num < def.min)
return `Must be at least ${def.min}`
if (def.max !== undefined && num > def.max)
return `Must be at most ${def.max}`
return null
}
case "boolean":
return value === "true" || value === "false"
? null
: 'Must be "true" or "false"'
case "enum":
return def.options?.includes(value)
? null
: `Must be one of: ${def.options?.join(", ")}`
default:
return null
}
}
export async function PUT(req: Request) {
const authError = checkAdminAuth(req)
if (authError) return authError
if (!isSettingsWritable()) {
return Response.json(
{
error: "Settings file is not writable on this deployment. Configure via environment variables instead.",
},
{ status: 503 },
)
}
let body: { values?: Record<string, unknown> }
try {
body = await req.json()
} catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
}
if (!body.values || typeof body.values !== "object") {
return Response.json(
{ error: "Body must contain a values object" },
{ status: 400 },
)
}
const updates: Record<string, string | null> = {}
const errors: Record<string, string> = {}
for (const [key, value] of Object.entries(body.values)) {
const def = SETTINGS_BY_KEY.get(key)
if (!def) {
errors[key] = "Unknown setting"
continue
}
if (value === null || value === "") {
updates[key] = null
continue
}
if (typeof value !== "string") {
errors[key] = "Value must be a string"
continue
}
const error = validateValue(def, value)
if (error) {
errors[key] = error
continue
}
updates[key] = value
}
if (Object.keys(errors).length > 0) {
return Response.json({ errors }, { status: 400 })
}
saveSettings(updates)
return Response.json({
writable: true,
settings: serializeSettings(),
})
}
+66
View File
@@ -0,0 +1,66 @@
import { POST as validateModel } from "@/app/api/validate-model/route"
import { checkAdminAuth } from "@/lib/admin/auth"
import {
AdminProviderSchema,
loadAdminProviders,
mergeSecrets,
} from "@/lib/admin/providers"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
// Test a model with the client's CURRENT provider state (which may be
// unsaved). Secret fields arrive either as plaintext (newly typed) or as
// masked {isSet} markers, which are resolved against settings.json — so
// testing works both before and after saving.
export async function POST(req: Request) {
const authError = checkAdminAuth(req)
if (authError) return authError
let body: { provider?: unknown; modelId?: string }
try {
body = await req.json()
} catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
}
const parsed = AdminProviderSchema.safeParse(body.provider)
if (!parsed.success || !body.modelId) {
return Response.json(
{ valid: false, error: "Invalid provider or model" },
{ status: 400 },
)
}
// SECURITY: a stored secret is only resolved from an {isSet} marker if
// the endpoint it would be sent to (provider + baseUrl) still matches
// the stored entry. Otherwise a tampered baseUrl could exfiltrate the
// stored key to an arbitrary host. Mismatches must re-supply plaintext.
const stored = loadAdminProviders().find((p) => p.id === parsed.data.id)
const sameEndpoint =
stored &&
stored.provider === parsed.data.provider &&
(stored.baseUrl ?? "") === (parsed.data.baseUrl ?? "") &&
(stored.awsRegion ?? "") === (parsed.data.awsRegion ?? "")
const [resolved] = mergeSecrets(
[parsed.data],
sameEndpoint && stored ? [stored] : [],
)
return validateModel(
new Request(new URL("/api/validate-model", req.url), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: resolved.provider,
apiKey: resolved.apiKey,
baseUrl: resolved.baseUrl,
modelId: body.modelId,
awsAccessKeyId: resolved.awsAccessKeyId,
awsSecretAccessKey: resolved.awsSecretAccessKey,
awsRegion: resolved.awsRegion,
vertexApiKey: resolved.vertexApiKey,
}),
}),
)
}
+1 -1
View File
@@ -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 },
+143 -715
View File
@@ -5,22 +5,24 @@ import {
Check,
ChevronRight,
Clock,
Cloud,
Eye,
EyeOff,
Key,
Link2,
Loader2,
Plus,
Server,
Settings2,
Sparkles,
Tag,
Trash2,
X,
Zap,
} from "lucide-react"
import { useCallback, useEffect, useRef, useState } from "react"
import {
ProviderCredentialsFields,
type SecretField,
} from "@/components/provider-credentials-fields"
import { ProviderLogo } from "@/components/provider-logo"
import {
AlertDialog,
AlertDialogAction,
@@ -54,11 +56,7 @@ import { useDictionary } from "@/hooks/use-dictionary"
import type { UseModelConfigReturn } from "@/hooks/use-model-config"
import { formatMessage } from "@/lib/i18n/utils"
import type { ProviderConfig, ProviderName } from "@/lib/types/model-config"
import {
PROVIDER_INFO,
PROVIDER_LOGO_MAP,
SUGGESTED_MODELS,
} from "@/lib/types/model-config"
import { PROVIDER_INFO, SUGGESTED_MODELS } from "@/lib/types/model-config"
import { cn } from "@/lib/utils"
interface ModelConfigDialogProps {
@@ -69,38 +67,6 @@ interface ModelConfigDialogProps {
type ValidationStatus = "idle" | "validating" | "success" | "error"
// Provider logo component
function ProviderLogo({
provider,
className,
}: {
provider: ProviderName
className?: string
}) {
// Use Lucide icons for providers without models.dev logos
if (provider === "bedrock") {
return <Cloud className={cn("size-4", className)} />
}
if (provider === "sglang") {
return <Server className={cn("size-4", className)} />
}
if (provider === "doubao") {
return <Sparkles className={cn("size-4", className)} />
}
const logoName = PROVIDER_LOGO_MAP[provider] || provider
return (
// biome-ignore lint/performance/noImgElement: External URL from models.dev
<img
alt={`${provider} logo`}
className={cn("size-4 dark:invert", className)}
height={16}
src={`https://models.dev/logos/${logoName}.svg`}
width={16}
/>
)
}
// Configuration section with title and optional action
function ConfigSection({
title,
@@ -379,6 +345,104 @@ export function ModelConfigDialog({
return provider.name || PROVIDER_INFO[provider.provider].label
}
// Inline Test button + error, shared across credential layouts. Disabled
// until the relevant credentials are present.
const renderTestButton = (canValidate: boolean) => (
<div className="flex items-center gap-2">
<Button
variant={validationStatus === "success" ? "outline" : "default"}
size="sm"
onClick={handleValidate}
disabled={!canValidate || validationStatus === "validating"}
className={cn(
"h-9 px-4",
validationStatus === "success" &&
"text-success border-success/30 bg-success-muted hover:bg-success-muted",
)}
>
{validationStatus === "validating" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : validationStatus === "success" ? (
<>
<Check className="h-4 w-4 mr-1.5 animate-check-pop" />
{dict.modelConfig.verified}
</>
) : (
dict.modelConfig.test
)}
</Button>
{validationStatus === "error" && validationError && (
<p className="text-xs text-destructive flex items-center gap-1">
<X className="h-3 w-3" />
{validationError}
</p>
)}
</div>
)
// Plaintext secret input with show/hide toggle (the user dialog stores
// keys client-side, so values are shown directly — unlike the masked
// admin panel). The primary key field carries the inline Test button.
const renderProviderSecret = (field: SecretField, id: string) => {
if (!selectedProvider) return null
const value = (selectedProvider[field] as string | undefined) ?? ""
// The "primary" credential sits beside the Test button; for Bedrock
// the test lives below the region, so its inputs have no inline test.
const isBedrock = selectedProvider.provider === "bedrock"
const withInlineTest =
!isBedrock && (field === "apiKey" || field === "vertexApiKey")
const canValidate =
field === "vertexApiKey"
? !!selectedProvider.vertexApiKey
: selectedProvider.provider === "ollama" ||
!!selectedProvider.apiKey
const input = (
<div className="relative flex-1">
<Input
id={id}
type={showApiKey ? "text" : "password"}
value={value}
onChange={(e) =>
handleProviderUpdate(field, e.target.value)
}
placeholder={
field === "awsSecretAccessKey"
? dict.modelConfig.enterSecretKey
: field === "awsAccessKeyId"
? "AKIA..."
: dict.modelConfig.enterApiKey
}
className="h-9 pr-10 font-mono text-xs"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
aria-label={
showApiKey
? dict.modelConfig.hideValue
: dict.modelConfig.showValue
}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded"
>
{showApiKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
)
if (!withInlineTest) return input
return (
<div className="space-y-2">
<div className="flex gap-2">
{input}
{renderTestButton(canValidate)}
</div>
</div>
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-4xl h-[80vh] max-h-[800px] overflow-hidden flex flex-col gap-0 p-0">
@@ -579,681 +643,45 @@ export function ModelConfigDialog({
icon={Settings2}
>
<ConfigCard>
{/* Display Name */}
<div className="space-y-2">
<Label
htmlFor="provider-name"
className="text-xs font-medium flex items-center gap-1.5"
>
<Tag className="h-3.5 w-3.5 text-muted-foreground" />
{
dict.modelConfig
.displayName
}
</Label>
<Input
id="provider-name"
value={
selectedProvider.name ||
""
}
onChange={(e) =>
handleProviderUpdate(
"name",
e.target.value,
)
}
placeholder={
PROVIDER_INFO[
selectedProvider
.provider
].label
}
className="h-9"
/>
</div>
{/* Credentials - different for Bedrock vs other providers */}
{selectedProvider.provider ===
"bedrock" ? (
<>
{/* AWS Access Key ID */}
<div className="space-y-2">
<Label
htmlFor="aws-access-key-id"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{
dict.modelConfig
.awsAccessKeyId
}
</Label>
<Input
id="aws-access-key-id"
type={
showApiKey
? "text"
: "password"
}
value={
selectedProvider.awsAccessKeyId ||
""
}
onChange={(e) =>
handleProviderUpdate(
"awsAccessKeyId",
e.target
.value,
)
}
placeholder="AKIA..."
className="h-9 font-mono text-xs"
/>
</div>
{/* AWS Secret Access Key */}
<div className="space-y-2">
<Label
htmlFor="aws-secret-access-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{
dict.modelConfig
.awsSecretAccessKey
}
</Label>
<div className="relative">
<Input
id="aws-secret-access-key"
type={
showApiKey
? "text"
: "password"
}
value={
selectedProvider.awsSecretAccessKey ||
""
}
onChange={(e) =>
handleProviderUpdate(
"awsSecretAccessKey",
e.target
.value,
)
}
placeholder={
dict
.modelConfig
.enterSecretKey
}
className="h-9 pr-10 font-mono text-xs"
/>
<button
type="button"
onClick={() =>
setShowApiKey(
!showApiKey,
)
}
aria-label={
showApiKey
? "Hide secret access key"
: "Show secret access key"
}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded"
>
{showApiKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
{/* AWS Region */}
<div className="space-y-2">
<Label
htmlFor="aws-region"
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{
dict.modelConfig
.awsRegion
}
</Label>
<Select
value={
selectedProvider.awsRegion ||
""
}
onValueChange={(
v,
) =>
handleProviderUpdate(
"awsRegion",
v,
)
}
>
<SelectTrigger className="h-9 font-mono text-xs hover:bg-accent">
<SelectValue
placeholder={
dict
.modelConfig
.selectRegion
}
/>
</SelectTrigger>
<SelectContent className="max-h-64">
<SelectItem value="us-east-1">
us-east-1
(N.
Virginia)
</SelectItem>
<SelectItem value="us-east-2">
us-east-2
(Ohio)
</SelectItem>
<SelectItem value="us-west-2">
us-west-2
(Oregon)
</SelectItem>
<SelectItem value="eu-west-1">
eu-west-1
(Ireland)
</SelectItem>
<SelectItem value="eu-west-2">
eu-west-2
(London)
</SelectItem>
<SelectItem value="eu-west-3">
eu-west-3
(Paris)
</SelectItem>
<SelectItem value="eu-central-1">
eu-central-1
(Frankfurt)
</SelectItem>
<SelectItem value="ap-south-1">
ap-south-1
(Mumbai)
</SelectItem>
<SelectItem value="ap-northeast-1">
ap-northeast-1
(Tokyo)
</SelectItem>
<SelectItem value="ap-northeast-2">
ap-northeast-2
(Seoul)
</SelectItem>
<SelectItem value="ap-southeast-1">
ap-southeast-1
(Singapore)
</SelectItem>
<SelectItem value="ap-southeast-2">
ap-southeast-2
(Sydney)
</SelectItem>
<SelectItem value="sa-east-1">
sa-east-1
(São Paulo)
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Test Button for Bedrock */}
<div className="flex items-center gap-2">
<Button
variant={
validationStatus ===
"success"
? "outline"
: "default"
}
size="sm"
onClick={
handleValidate
}
disabled={
!selectedProvider.awsAccessKeyId ||
!selectedProvider.awsSecretAccessKey ||
!selectedProvider.awsRegion ||
validationStatus ===
"validating"
}
className={cn(
"h-9 px-4",
validationStatus ===
"success" &&
"text-success border-success/30 bg-success-muted hover:bg-success-muted",
)}
>
{validationStatus ===
"validating" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : validationStatus ===
"success" ? (
<>
<Check className="h-4 w-4 mr-1.5 animate-check-pop" />
{
dict
.modelConfig
.verified
}
</>
) : (
dict.modelConfig
.test
)}
</Button>
{validationStatus ===
"error" &&
validationError && (
<p className="text-xs text-destructive flex items-center gap-1">
<X className="h-3 w-3" />
{
validationError
}
</p>
)}
</div>
</>
) : selectedProvider.provider ===
"vertexai" ? (
<>
{/* Vertex AI API Key */}
<div className="space-y-2">
<Label
htmlFor="vertex-api-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
API Key
</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id="vertex-api-key"
type={
showApiKey
? "text"
: "password"
}
value={
selectedProvider.vertexApiKey ||
""
}
onChange={(
e,
) =>
handleProviderUpdate(
"vertexApiKey",
e
.target
.value,
)
}
placeholder="Enter your Vertex AI API key"
className="h-9 pr-10 font-mono text-xs"
/>
<button
type="button"
onClick={() =>
setShowApiKey(
!showApiKey,
)
}
aria-label={
showApiKey
? "Hide API key"
: "Show API key"
}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded"
>
{showApiKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
<Button
variant={
validationStatus ===
"success"
? "outline"
: "default"
}
size="sm"
onClick={
handleValidate
}
disabled={
!selectedProvider.vertexApiKey ||
validationStatus ===
"validating"
}
className={cn(
"h-9 px-4",
validationStatus ===
"success" &&
"text-success border-success/30 bg-success-muted hover:bg-success-muted",
)}
>
{validationStatus ===
"validating" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : validationStatus ===
"success" ? (
<>
<Check className="h-4 w-4 mr-1.5 animate-check-pop" />
{
dict
.modelConfig
.verified
}
</>
) : (
dict
.modelConfig
.test
)}
</Button>
</div>
{validationStatus ===
"error" &&
validationError && (
<p className="text-xs text-destructive flex items-center gap-1">
<X className="h-3 w-3" />
{
validationError
}
</p>
)}
</div>
{/* Base URL (optional) */}
<div className="space-y-2">
<Label
htmlFor="vertex-base-url"
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{formatMessage(
dict.modelConfig
.baseUrlWithExample,
{
example:
PROVIDER_INFO[
selectedProvider
.provider
]
.defaultBaseUrl ||
"https://api.example.com/v1",
},
)}
</Label>
<Input
id="vertex-base-url"
value={
selectedProvider.baseUrl ||
""
}
onChange={(e) =>
handleProviderUpdate(
"baseUrl",
e.target
.value,
)
}
placeholder="Custom endpoint URL"
className="h-9 font-mono text-xs"
/>
</div>
</>
) : selectedProvider.provider ===
"edgeone" ? (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Button
variant={
validationStatus ===
"success"
? "outline"
: "default"
}
size="sm"
onClick={
handleValidate
}
disabled={
validationStatus ===
"validating"
}
className={cn(
"h-9 px-4",
validationStatus ===
"success" &&
"text-success border-success/30 bg-success-muted hover:bg-success-muted",
)}
>
{validationStatus ===
"validating" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : validationStatus ===
"success" ? (
<>
<Check className="h-4 w-4 mr-1.5" />
{
dict
.modelConfig
.verified
}
</>
) : (
dict.modelConfig
.test
)}
</Button>
{validationStatus ===
"error" &&
validationError && (
<p className="text-xs text-destructive flex items-center gap-1">
<X className="h-3 w-3" />
{
validationError
}
</p>
)}
</div>
</div>
) : (
<>
{/* API Key */}
<div className="space-y-2">
<Label
htmlFor="api-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{
dict.modelConfig
.apiKey
}
{selectedProvider.provider ===
"ollama" &&
` ${dict.modelConfig.optional}`}
</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id="api-key"
type={
showApiKey
? "text"
: "password"
}
value={
selectedProvider.apiKey
}
onChange={(
e,
) =>
handleProviderUpdate(
"apiKey",
e
.target
.value,
)
}
placeholder={
dict
.modelConfig
.enterApiKey
}
className="h-9 pr-10 font-mono text-xs"
/>
<button
type="button"
onClick={() =>
setShowApiKey(
!showApiKey,
)
}
aria-label={
showApiKey
? "Hide API key"
: "Show API key"
}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded"
>
{showApiKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
<Button
variant={
validationStatus ===
"success"
? "outline"
: "default"
}
size="sm"
onClick={
handleValidate
}
disabled={
(selectedProvider.provider !==
"ollama" &&
!selectedProvider.apiKey) ||
validationStatus ===
"validating"
}
className={cn(
"h-9 px-4",
validationStatus ===
"success" &&
"text-success border-success/30 bg-success-muted hover:bg-success-muted",
)}
>
{validationStatus ===
"validating" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : validationStatus ===
"success" ? (
<>
<Check className="h-4 w-4 mr-1.5 animate-check-pop" />
{
dict
.modelConfig
.verified
}
</>
) : (
dict
.modelConfig
.test
)}
</Button>
</div>
{validationStatus ===
"error" &&
validationError && (
<p className="text-xs text-destructive flex items-center gap-1">
<X className="h-3 w-3" />
{
validationError
}
</p>
)}
</div>
{/* Base URL */}
<div className="space-y-2">
<Label
htmlFor="base-url"
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{formatMessage(
dict.modelConfig
.baseUrlWithExample,
{
example:
PROVIDER_INFO[
selectedProvider
.provider
]
.defaultBaseUrl ||
"https://api.example.com/v1",
},
)}
</Label>
<Input
id="base-url"
value={
selectedProvider.baseUrl ||
""
}
onChange={(e) =>
handleProviderUpdate(
"baseUrl",
e.target
.value,
)
}
placeholder={
PROVIDER_INFO[
selectedProvider
.provider
]
.defaultBaseUrl ||
dict.modelConfig
.customEndpoint
}
className="h-9 rounded-xl font-mono text-xs"
/>
{selectedProvider.provider ===
"minimax" && (
<p className="text-xs text-muted-foreground">
{
dict
.modelConfig
.minimaxBaseUrlHint
}
</p>
)}
</div>
</>
)}
<ProviderCredentialsFields
provider={
selectedProvider.provider
}
name={selectedProvider.name}
baseUrl={
selectedProvider.baseUrl
}
awsRegion={
selectedProvider.awsRegion
}
onChange={(field, value) =>
handleProviderUpdate(
field,
value,
)
}
renderSecret={({ field, id }) =>
renderProviderSecret(
field,
id,
)
}
footer={
selectedProvider.provider ===
"bedrock"
? renderTestButton(
!!selectedProvider.awsAccessKeyId &&
!!selectedProvider.awsSecretAccessKey &&
!!selectedProvider.awsRegion,
)
: selectedProvider.provider ===
"edgeone"
? renderTestButton(
true,
)
: undefined
}
/>
</ConfigCard>
</ConfigSection>
+259
View File
@@ -0,0 +1,259 @@
"use client"
import { Key, Link2, Tag } from "lucide-react"
import type { ReactNode } from "react"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
// Logical secret field. The caller owns the actual input — plaintext for the
// user dialog, write-only masked for the admin panel — supplied via
// renderSecret. That (and the optional test action) are the only genuine
// differences between the two screens; the field structure is shared here.
export type SecretField =
| "apiKey"
| "awsAccessKeyId"
| "awsSecretAccessKey"
| "vertexApiKey"
// AWS regions offered for Bedrock (shared by both screens)
const AWS_REGIONS: Array<[string, string]> = [
["us-east-1", "N. Virginia"],
["us-east-2", "Ohio"],
["us-west-2", "Oregon"],
["eu-west-1", "Ireland"],
["eu-west-2", "London"],
["eu-west-3", "Paris"],
["eu-central-1", "Frankfurt"],
["ap-south-1", "Mumbai"],
["ap-northeast-1", "Tokyo"],
["ap-northeast-2", "Seoul"],
["ap-southeast-1", "Singapore"],
["ap-southeast-2", "Sydney"],
["sa-east-1", "São Paulo"],
]
interface ProviderCredentialsFieldsProps {
provider: ProviderName
// Plain (non-secret) field values — secrets are owned by renderSecret
name?: string
baseUrl?: string
awsRegion?: string
disabled?: boolean
// Update a plain text field
onChange: (field: "name" | "baseUrl" | "awsRegion", value: string) => void
// Render the control for a secret field. The caller may include trailing
// UI (e.g. the user dialog's inline Test button + validation error); the
// shared component only supplies the label above it.
renderSecret: (opts: { field: SecretField; id: string }) => ReactNode
// Extra content after the fields — used for the Bedrock test row and the
// EdgeOne test button, which aren't beside a credential input.
footer?: ReactNode
}
// Display name + per-provider credential inputs, shared by the user
// ModelConfigDialog and the admin Models panel.
export function ProviderCredentialsFields({
provider,
name,
baseUrl,
awsRegion,
disabled,
onChange,
renderSecret,
footer,
}: ProviderCredentialsFieldsProps) {
const dict = useDictionary()
const info = PROVIDER_INFO[provider]
const baseUrlLabel = formatMessage(dict.modelConfig.baseUrlWithExample, {
example: info.defaultBaseUrl || "https://api.example.com/v1",
})
// EdgeOne needs no credentials — the caller supplies just a test button
if (provider === "edgeone") {
return <div className="space-y-5">{footer}</div>
}
return (
<div className="space-y-5">
{/* Display Name */}
<div className="space-y-2">
<Label
htmlFor="provider-name"
className="text-xs font-medium flex items-center gap-1.5"
>
<Tag className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.displayName}
</Label>
<Input
id="provider-name"
value={name ?? ""}
disabled={disabled}
onChange={(e) => onChange("name", e.target.value)}
placeholder={info.label}
className="h-9"
/>
</div>
{provider === "bedrock" ? (
<>
{/* AWS Access Key ID */}
<div className="space-y-2">
<Label
htmlFor="aws-access-key-id"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.awsAccessKeyId}
</Label>
{renderSecret({
field: "awsAccessKeyId",
id: "aws-access-key-id",
})}
</div>
{/* AWS Secret Access Key */}
<div className="space-y-2">
<Label
htmlFor="aws-secret-access-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.awsSecretAccessKey}
</Label>
{renderSecret({
field: "awsSecretAccessKey",
id: "aws-secret-access-key",
})}
</div>
{/* AWS Region */}
<div className="space-y-2">
<Label
htmlFor="aws-region"
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.awsRegion}
</Label>
<Select
value={awsRegion || ""}
disabled={disabled}
onValueChange={(v) => onChange("awsRegion", v)}
>
<SelectTrigger
id="aws-region"
className="h-9 font-mono text-xs hover:bg-accent"
>
<SelectValue
placeholder={dict.modelConfig.selectRegion}
/>
</SelectTrigger>
<SelectContent className="max-h-64">
{AWS_REGIONS.map(([region, label]) => (
<SelectItem key={region} value={region}>
{region} ({label})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
) : provider === "vertexai" ? (
<>
{/* Vertex AI API Key (Express Mode) */}
<div className="space-y-2">
<Label
htmlFor="vertex-api-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.apiKey}
</Label>
{renderSecret({
field: "vertexApiKey",
id: "vertex-api-key",
})}
</div>
{/* Base URL (optional) */}
<div className="space-y-2">
<Label
htmlFor="vertex-base-url"
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{baseUrlLabel}
</Label>
<Input
id="vertex-base-url"
value={baseUrl ?? ""}
disabled={disabled}
onChange={(e) =>
onChange("baseUrl", e.target.value)
}
placeholder={dict.modelConfig.customEndpoint}
className="h-9 font-mono text-xs"
/>
</div>
</>
) : (
<>
{/* API Key */}
<div className="space-y-2">
<Label
htmlFor="api-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.apiKey}
{provider === "ollama" &&
` ${dict.modelConfig.optional}`}
</Label>
{renderSecret({ field: "apiKey", id: "api-key" })}
</div>
{/* Base URL */}
<div className="space-y-2">
<Label
htmlFor="base-url"
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{baseUrlLabel}
</Label>
<Input
id="base-url"
value={baseUrl ?? ""}
disabled={disabled}
onChange={(e) =>
onChange("baseUrl", e.target.value)
}
placeholder={
info.defaultBaseUrl ||
dict.modelConfig.customEndpoint
}
className="h-9 rounded-xl font-mono text-xs"
/>
{provider === "minimax" && (
<p className="text-xs text-muted-foreground">
{dict.modelConfig.minimaxBaseUrlHint}
</p>
)}
</div>
</>
)}
{footer}
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
import { Cloud, Server, Sparkles } from "lucide-react"
import { PROVIDER_LOGO_MAP, type ProviderName } from "@/lib/types/model-config"
import { cn } from "@/lib/utils"
// Provider logo from models.dev, with Lucide fallbacks for providers
// that have no logo there
export function ProviderLogo({
provider,
className,
}: {
provider: ProviderName
className?: string
}) {
if (provider === "bedrock") {
return <Cloud className={cn("size-4", className)} />
}
if (provider === "sglang") {
return <Server className={cn("size-4", className)} />
}
if (provider === "doubao") {
return <Sparkles className={cn("size-4", className)} />
}
const logoName = PROVIDER_LOGO_MAP[provider] || provider
return (
// biome-ignore lint/performance/noImgElement: External URL from models.dev
<img
alt=""
aria-hidden="true"
className={cn("size-4 dark:invert", className)}
height={16}
src={`https://models.dev/logos/${logoName}.svg`}
width={16}
/>
)
}
+3
View File
@@ -11,6 +11,9 @@ services:
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
ports: ["3000:3000"]
env_file: .env
volumes:
# Persists admin panel settings (data/settings.json)
- ./data:/app/data
# environment:
# # For subdirectory deployment, uncomment and set your path:
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
+6
View File
@@ -222,6 +222,12 @@ npm run dev
注意:`claude` 系列已在带有 AWS、Azure、GCP 等云架构 Logo 的 draw.io 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。
### 管理面板
设置 `ADMIN_PASSWORD` 环境变量并访问 `/admin`,即可在 Web 面板中管理服务端设置(模型、访问码、功能开关、可观测性、配额),无需手动编辑 `.env`
📖 **[管理面板指南](./admin-panel.md)** — 启用方法、优先级规则和注意事项。
## 工作原理
+24
View File
@@ -0,0 +1,24 @@
# 管理面板
无需手动编辑 `.env`,您可以在 Web 管理面板中管理服务端设置。
## 启用面板
1. 设置 `ADMIN_PASSWORD` 环境变量(不设置则面板禁用)。
2. 访问 `/admin` 并登录。
## 可配置内容
1. **Models(模型)** — 添加提供商及其 API Key 和模型列表,交互与应用内的模型设置相同。保存后这些模型成为所有用户可用的服务端模型,并在请求时与环境中的 `AI_MODELS_CONFIG` / `ai-models.json` 合并(面板不会修改这些环境文件)。
2. **其余区块** — 访问码、生成参数、功能开关、可观测性和配额。保存的设置会写入 `data/settings.json` 并立即生效,无需重启(少数设置如 Langfuse 和 DynamoDB 标记为"需要重启")。
## 优先级
面板中保存的设置覆盖环境变量,环境变量覆盖内置默认值。删除已保存的值会回退到环境变量。
## 注意事项
- 密钥以明文形式存储在 `data/settings.json` 中(文件权限 600),请妥善保管该文件。
- 在无服务器平台(Vercel、Cloudflare Workers)上没有持久化磁盘,面板为只读 — 请改用环境变量配置。
- 使用 Docker 时,`data/` 目录通过 `docker-compose.yml` 中的卷持久化。
- `NEXT_PUBLIC_*` 变量在构建时固化,无法在面板中修改。
+24
View File
@@ -0,0 +1,24 @@
# Admin Panel
Instead of hand-editing `.env`, you can manage server settings in a web admin panel.
## Enabling the panel
1. Set the `ADMIN_PASSWORD` environment variable (leave unset to disable the panel).
2. Visit `/admin` and sign in.
## What you can configure
1. **Models** — add providers with their API keys and model lists, using the same UI as the in-app model settings. Saved models become server-side models available to all users, merged with any `AI_MODELS_CONFIG` / `ai-models.json` from your environment at request time (the panel does not modify those env files).
2. **Other sections** — access codes, generation parameters, features, observability, and quota. Saved settings are written to `data/settings.json` and apply immediately — no restart needed (a few settings such as Langfuse and DynamoDB are marked "Restart Required").
## Precedence
Settings saved in the panel override environment variables, which override built-in defaults. Removing a saved value falls back to the environment variable.
## Notes
- Secrets are stored in plaintext in `data/settings.json` (file mode 600). Keep the file private.
- On serverless platforms (Vercel, Cloudflare Workers) there is no persistent disk, so the panel is read-only — configure via environment variables there.
- With Docker, the `data/` directory is persisted via the volume in `docker-compose.yml`.
- `NEXT_PUBLIC_*` variables are baked in at build time and cannot be changed in the panel.
+6
View File
@@ -221,6 +221,12 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
注:`claude`シリーズはAWS、Azure、GCPなどのクラウドアーキテクチャロゴ付きのdraw.ioダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。
### 管理パネル
`ADMIN_PASSWORD` 環境変数を設定して `/admin` にアクセスすると、`.env` を手動で編集する代わりに Web パネルでサーバー設定(モデル、アクセスコード、機能、可観測性、クォータ)を管理できます。
📖 **[管理パネルガイド](./admin-panel.md)** — 有効化の方法、優先順位ルール、注意事項。
## 仕組み
+24
View File
@@ -0,0 +1,24 @@
# 管理パネル
`.env` を手動で編集する代わりに、Web 管理パネルでサーバー設定を管理できます。
## パネルの有効化
1. `ADMIN_PASSWORD` 環境変数を設定します(未設定の場合、パネルは無効になります)。
2. `/admin` にアクセスしてサインインします。
## 設定できる項目
1. **Models(モデル)** — アプリ内のモデル設定と同じ UI で、プロバイダーの API キーとモデルリストを追加します。保存するとそれらは全ユーザーが利用できるサーバーサイドモデルになり、リクエスト時に環境の `AI_MODELS_CONFIG` / `ai-models.json` とマージされます(パネルがこれらの環境ファイルを変更することはありません)。
2. **その他のセクション** — アクセスコード、生成パラメータ、機能、可観測性、クォータ。保存された設定は `data/settings.json` に書き込まれ、即座に反映されます — 再起動は不要です(Langfuse や DynamoDB など一部の設定は「再起動が必要」と表示されます)。
## 優先順位
パネルで保存された設定は環境変数を上書きし、環境変数は組み込みのデフォルト値を上書きします。保存した値を削除すると環境変数にフォールバックします。
## 注意事項
- シークレットは `data/settings.json` に平文で保存されます(ファイルモード 600)。このファイルは非公開に保ってください。
- サーバーレスプラットフォーム(Vercel、Cloudflare Workers)には永続ディスクがないため、パネルは読み取り専用です — その環境では環境変数で設定してください。
- Docker 使用時は、`data/` ディレクトリが `docker-compose.yml` のボリュームで永続化されます。
- `NEXT_PUBLIC_*` 変数はビルド時に固定され、パネルでは変更できません。
+8
View File
@@ -116,6 +116,14 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# Access Control (Optional)
# ACCESS_CODE_LIST=your-secret-code,another-code
# Admin Panel (Optional)
# Set a password to enable the web admin panel at /admin, where most of the
# settings in this file can be edited at runtime (stored in data/settings.json,
# which takes precedence over environment variables).
# Leave unset to disable the admin panel entirely.
# ADMIN_PASSWORD=your-admin-password
# SETTINGS_FILE=./data/settings.json # Optional: custom settings file location
# Draw.io Configuration (Optional)
# NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net # Default: https://embed.diagrams.net
# Use this to point to a self-hosted draw.io instance
+11 -1
View File
@@ -1,7 +1,17 @@
import { LangfuseSpanProcessor } from "@langfuse/otel"
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"
export function register() {
export async function register() {
// Overlay admin settings file onto process.env before anything reads config
if (process.env.NEXT_RUNTIME === "nodejs") {
try {
const { applyToEnv } = await import("@/lib/admin/settings")
applyToEnv()
} catch (err) {
console.error("[admin-settings] Failed to apply settings:", err)
}
}
// Skip telemetry if Langfuse env vars are not configured
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
console.warn(
+37
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
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]
}
+229
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
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]
}
+1 -1
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",
+147 -1
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."
}
}
}
}
+147 -1
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 日のリセット境界に使用するタイムゾーン。"
}
}
}
}
+147 -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": "每日重置邊界所用的時區。"
}
}
}
}
+147 -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": "每日重置边界所用的时区。"
}
}
}
}
+35 -1
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[]
> {
+4 -1
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"
}
+9
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",
+398
View File
@@ -0,0 +1,398 @@
import fs from "fs"
import os from "os"
import path from "path"
import { afterEach, beforeEach, describe, expect, it } from "vitest"
import {
ADMIN_PROVIDERS_KEY,
adminProvidersToConfig,
deriveEnvUpdates,
loadAdminProviders,
maskAdminProviders,
mergeSecrets,
type StoredAdminProvider,
validateAdminProviders,
} from "@/lib/admin/providers"
import { _resetForTests, saveSettings } from "@/lib/admin/settings"
import { loadRawServerModelsConfig } from "@/lib/server-model-config"
let tmpDir: string
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "admin-providers-"))
process.env.SETTINGS_FILE = path.join(tmpDir, "settings.json")
_resetForTests()
})
afterEach(() => {
_resetForTests()
delete process.env.SETTINGS_FILE
delete process.env.AI_MODELS_CONFIG
fs.rmSync(tmpDir, { recursive: true, force: true })
})
function provider(
overrides: Partial<StoredAdminProvider> = {},
): StoredAdminProvider {
return {
id: "p1",
provider: "openai",
apiKey: "sk-test",
models: ["gpt-5.2"],
...overrides,
}
}
describe("deriveEnvUpdates", () => {
it("writes credentials to ADMIN_-prefixed env vars (never shadows standard vars)", () => {
const updates = deriveEnvUpdates([provider()], [])
expect(updates.ADMIN_OPENAI_API_KEY).toBe("sk-test")
expect(updates.OPENAI_API_KEY).toBeUndefined()
expect(JSON.parse(updates.ADMIN_PROVIDERS as string)).toHaveLength(1)
// AI_MODELS_CONFIG is no longer written (merged at read time)
expect(updates.AI_MODELS_CONFIG).toBeNull()
})
it("suffixes env vars for a second instance of the same provider", () => {
const updates = deriveEnvUpdates(
[
provider({ id: "p1", name: "First" }),
provider({
id: "p2",
name: "Second",
apiKey: "sk-second",
models: ["gpt-5-mini"],
}),
],
[],
)
expect(updates.ADMIN_OPENAI_API_KEY).toBe("sk-test")
expect(updates.ADMIN_OPENAI_API_KEY_2).toBe("sk-second")
})
it("maps bedrock credentials to AWS env vars", () => {
const updates = deriveEnvUpdates(
[
provider({
provider: "bedrock",
apiKey: undefined,
awsAccessKeyId: "AKIA123",
awsSecretAccessKey: "secret",
awsRegion: "us-west-2",
models: ["claude-x"],
}),
],
[],
)
expect(updates.AWS_ACCESS_KEY_ID).toBe("AKIA123")
expect(updates.AWS_SECRET_ACCESS_KEY).toBe("secret")
expect(updates.AWS_REGION).toBe("us-west-2")
})
it("clears keys owned by the previous list when providers are removed", () => {
const prev = [provider()]
const updates = deriveEnvUpdates([], prev)
expect(updates.ADMIN_OPENAI_API_KEY).toBeNull()
expect(updates.AI_MODELS_CONFIG).toBeNull()
expect(updates.ADMIN_PROVIDERS).toBeNull()
})
it("sets AI_PROVIDER/AI_MODEL only when a default is flagged", () => {
const noDefault = deriveEnvUpdates([provider()], [])
expect(noDefault.AI_PROVIDER).toBeNull()
expect(noDefault.AI_MODEL).toBeNull()
const updates = deriveEnvUpdates(
[
provider({ id: "p1" }),
provider({
id: "p2",
provider: "deepseek",
models: ["deepseek-chat"],
isDefault: true,
}),
],
[],
)
expect(updates.AI_PROVIDER).toBe("deepseek")
expect(updates.AI_MODEL).toBe("deepseek-chat")
})
})
describe("adminProvidersToConfig", () => {
it("builds a config with ADMIN_-prefixed apiKeyEnv wiring", () => {
const config = adminProvidersToConfig([provider()])
expect(config.providers).toHaveLength(1)
expect(config.providers[0].models).toEqual(["gpt-5.2"])
expect(config.providers[0].apiKeyEnv).toBe("ADMIN_OPENAI_API_KEY")
})
it("wires suffixed env vars for a second instance", () => {
const config = adminProvidersToConfig([
provider({ id: "p1", name: "First" }),
provider({
id: "p2",
name: "Second",
apiKey: "sk-second",
models: ["gpt-5-mini"],
}),
])
expect(config.providers[1].apiKeyEnv).toBe("ADMIN_OPENAI_API_KEY_2")
})
it("skips providers without models and carries the default flag", () => {
const config = adminProvidersToConfig([
provider({ id: "p1", models: [] }),
provider({ id: "p2", name: "D", isDefault: true }),
])
expect(config.providers).toHaveLength(1)
expect(config.providers[0].default).toBe(true)
})
})
describe("mergeSecrets", () => {
it("keeps stored secret when client sends an isSet marker", () => {
const stored = [provider({ apiKey: "sk-original" })]
const merged = mergeSecrets(
[
{
...provider(),
apiKey: { isSet: true, hint: "…test" },
},
],
stored,
)
expect(merged[0].apiKey).toBe("sk-original")
})
it("replaces secret when client sends a plaintext string", () => {
const stored = [provider({ apiKey: "sk-original" })]
const merged = mergeSecrets(
[{ ...provider(), apiKey: "sk-new" }],
stored,
)
expect(merged[0].apiKey).toBe("sk-new")
})
it("clears secret when client sends undefined", () => {
const stored = [provider({ apiKey: "sk-original" })]
const merged = mergeSecrets(
[{ ...provider(), apiKey: undefined }],
stored,
)
expect(merged[0].apiKey).toBeUndefined()
})
})
describe("loadRawServerModelsConfig merge", () => {
it("combines env AI_MODELS_CONFIG with panel providers", async () => {
process.env.AI_MODELS_CONFIG = JSON.stringify({
providers: [
{
name: "Env OpenAI",
provider: "openai",
models: ["gpt-from-env"],
default: true,
},
],
})
saveSettings(deriveEnvUpdates([provider({ name: "Panel" })], []))
const merged = await loadRawServerModelsConfig()
expect(merged?.providers.map((p) => p.name)).toEqual([
"Env OpenAI",
"Panel",
])
// Env default kept because panel set none
expect(merged?.providers[0].default).toBe(true)
})
it("panel default overrides the env default", async () => {
process.env.AI_MODELS_CONFIG = JSON.stringify({
providers: [
{
name: "Env OpenAI",
provider: "openai",
models: ["gpt-from-env"],
default: true,
},
],
})
saveSettings(
deriveEnvUpdates(
[provider({ name: "Panel", isDefault: true })],
[],
),
)
const merged = await loadRawServerModelsConfig()
expect(merged?.providers[0].default).toBeFalsy()
expect(merged?.providers[1].default).toBe(true)
})
it("returns only env config when the panel has no providers", async () => {
process.env.AI_MODELS_CONFIG = JSON.stringify({
providers: [
{
name: "Env Only",
provider: "openai",
models: ["gpt-from-env"],
},
],
})
const merged = await loadRawServerModelsConfig()
expect(merged?.providers.map((p) => p.name)).toEqual(["Env Only"])
})
})
describe("validateAdminProviders", () => {
it("rejects names clashing with env-configured providers", () => {
expect(
validateAdminProviders([provider({ name: "Env OpenAI" })], {
providers: [
{
name: "Env OpenAI",
provider: "openai",
models: ["gpt-x"],
},
],
}),
).toMatch(/already defined/)
})
it("rejects a global-credential provider already in the env config", () => {
expect(
validateAdminProviders(
[
provider({
provider: "bedrock",
apiKey: undefined,
awsAccessKeyId: "AKIA-panel",
awsSecretAccessKey: "panel-secret",
awsRegion: "us-east-1",
models: ["claude-x"],
}),
],
{
providers: [
{
name: "Env Bedrock",
provider: "bedrock",
models: ["claude-env"],
},
],
},
),
).toMatch(/shares global credentials/)
})
it("allows a normal provider type alongside the same env type", () => {
expect(
validateAdminProviders([provider({ name: "Panel OpenAI" })], {
providers: [
{
name: "Env OpenAI",
provider: "openai",
models: ["gpt-x"],
},
],
}),
).toBeNull()
})
it("rejects two bedrock instances", () => {
const list = [
provider({ id: "p1", provider: "bedrock" }),
provider({ id: "p2", provider: "bedrock" }),
]
expect(validateAdminProviders(list)).toMatch(/Only one/)
})
it("rejects duplicate display names", () => {
const list = [
provider({ id: "p1", name: "Same" }),
provider({ id: "p2", name: "Same" }),
]
expect(validateAdminProviders(list)).toMatch(/unique/)
})
it("rejects multiple defaults", () => {
const list = [
provider({ id: "p1", isDefault: true }),
provider({ id: "p2", name: "Other", isDefault: true }),
]
expect(validateAdminProviders(list)).toMatch(/default/)
})
it("accepts a valid list", () => {
const list = [
provider({ id: "p1", isDefault: true }),
provider({ id: "p2", name: "Backup" }),
]
expect(validateAdminProviders(list)).toBeNull()
})
})
describe("loadAdminProviders", () => {
it("returns [] when nothing is stored", () => {
expect(loadAdminProviders()).toEqual([])
})
it("loads valid stored providers", () => {
saveSettings({ [ADMIN_PROVIDERS_KEY]: JSON.stringify([provider()]) })
expect(loadAdminProviders()).toHaveLength(1)
})
it("round-trips a bedrock provider with multiple string secrets", () => {
const bedrock = provider({
provider: "bedrock",
apiKey: undefined,
awsAccessKeyId: "AKIA123",
awsSecretAccessKey: "secret",
awsRegion: "us-west-2",
models: ["claude-x"],
})
saveSettings({ [ADMIN_PROVIDERS_KEY]: JSON.stringify([bedrock]) })
const loaded = loadAdminProviders()
expect(loaded).toHaveLength(1)
expect(loaded[0].awsAccessKeyId).toBe("AKIA123")
expect(loaded[0].awsSecretAccessKey).toBe("secret")
})
it("drops malformed entries and keeps valid ones", () => {
saveSettings({
[ADMIN_PROVIDERS_KEY]: JSON.stringify([
provider({ id: "good" }),
{ id: "missing-fields" }, // no provider/models
{ provider: "openai", models: ["x"] }, // no id
"not-an-object",
]),
})
const loaded = loadAdminProviders()
expect(loaded).toHaveLength(1)
expect(loaded[0].id).toBe("good")
})
it("returns [] when the stored value is not an array", () => {
saveSettings({ [ADMIN_PROVIDERS_KEY]: JSON.stringify({ nope: true }) })
expect(loadAdminProviders()).toEqual([])
})
it("returns [] on invalid JSON", () => {
saveSettings({ [ADMIN_PROVIDERS_KEY]: "{ broken" })
expect(loadAdminProviders()).toEqual([])
})
it("drops entries whose secret is an {isSet} marker, not a string", () => {
// A hand-edited file could hold a transit-only marker object; if it
// slipped through, maskSecret() would throw on a non-string value.
saveSettings({
[ADMIN_PROVIDERS_KEY]: JSON.stringify([
{ ...provider(), apiKey: { isSet: true, hint: "…1234" } },
]),
})
const loaded = loadAdminProviders()
expect(loaded).toEqual([])
// Masking the loaded list must not throw
expect(() => maskAdminProviders(loaded)).not.toThrow()
})
})
+158
View File
@@ -0,0 +1,158 @@
import fs from "fs"
import os from "os"
import path from "path"
import { afterEach, beforeEach, describe, expect, it } from "vitest"
import {
_resetForTests,
applyToEnv,
getEnvFallback,
getValueSource,
isSettingsWritable,
loadSettings,
saveSettings,
} from "@/lib/admin/settings"
let tmpDir: string
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "admin-settings-"))
process.env.SETTINGS_FILE = path.join(tmpDir, "settings.json")
_resetForTests()
})
afterEach(() => {
_resetForTests()
delete process.env.SETTINGS_FILE
fs.rmSync(tmpDir, { recursive: true, force: true })
delete process.env.TEST_ADMIN_VAR
})
describe("loadSettings", () => {
it("returns empty object when file does not exist", () => {
expect(loadSettings()).toEqual({})
})
it("reads values from the settings file", () => {
fs.writeFileSync(
process.env.SETTINGS_FILE!,
JSON.stringify({ version: 1, values: { TEST_ADMIN_VAR: "abc" } }),
)
expect(loadSettings()).toEqual({ TEST_ADMIN_VAR: "abc" })
})
it("drops non-string values from a corrupted file", () => {
fs.writeFileSync(
process.env.SETTINGS_FILE!,
JSON.stringify({
version: 1,
values: {
GOOD: "ok",
NUM: 5,
OBJ: { nested: true },
ARR: [1, 2],
NULL: null,
},
}),
)
expect(loadSettings()).toEqual({ GOOD: "ok" })
})
it("returns empty object when values is null", () => {
fs.writeFileSync(
process.env.SETTINGS_FILE!,
JSON.stringify({ version: 1, values: null }),
)
expect(loadSettings()).toEqual({})
})
it("returns empty object when values is an array (no numeric keys)", () => {
fs.writeFileSync(
process.env.SETTINGS_FILE!,
JSON.stringify({ version: 1, values: ["a", "b"] }),
)
// Without the Array.isArray guard this would yield { "0": "a", ... }
expect(loadSettings()).toEqual({})
})
})
describe("applyToEnv / saveSettings", () => {
it("overlays file values onto process.env", () => {
saveSettings({ TEST_ADMIN_VAR: "from-file" })
expect(process.env.TEST_ADMIN_VAR).toBe("from-file")
})
it("file value wins over pre-existing env value", () => {
process.env.TEST_ADMIN_VAR = "from-env"
saveSettings({ TEST_ADMIN_VAR: "from-file" })
expect(process.env.TEST_ADMIN_VAR).toBe("from-file")
})
it("deleting a key restores the original env value", () => {
process.env.TEST_ADMIN_VAR = "from-env"
saveSettings({ TEST_ADMIN_VAR: "from-file" })
saveSettings({ TEST_ADMIN_VAR: null })
expect(process.env.TEST_ADMIN_VAR).toBe("from-env")
})
it("deleting a key unsets env when there was no original value", () => {
saveSettings({ TEST_ADMIN_VAR: "from-file" })
saveSettings({ TEST_ADMIN_VAR: null })
expect(process.env.TEST_ADMIN_VAR).toBeUndefined()
})
it("persists across cache reset (file round-trip)", () => {
saveSettings({ TEST_ADMIN_VAR: "persisted" })
_resetForTests()
applyToEnv()
expect(process.env.TEST_ADMIN_VAR).toBe("persisted")
})
})
describe("getValueSource / getEnvFallback", () => {
it("reports file source when key is in settings", () => {
saveSettings({ TEST_ADMIN_VAR: "x" })
expect(getValueSource("TEST_ADMIN_VAR")).toBe("file")
})
it("reports env source when only env is set", () => {
process.env.TEST_ADMIN_VAR = "from-env"
applyToEnv()
expect(getValueSource("TEST_ADMIN_VAR")).toBe("env")
})
it("reports default when neither is set", () => {
expect(getValueSource("TEST_ADMIN_VAR")).toBe("default")
})
it("returns the shadowed env value as fallback", () => {
process.env.TEST_ADMIN_VAR = "from-env"
saveSettings({ TEST_ADMIN_VAR: "from-file" })
expect(getEnvFallback("TEST_ADMIN_VAR")).toBe("from-env")
})
})
describe("isSettingsWritable", () => {
it("returns true for a writable temp dir", () => {
expect(isSettingsWritable()).toBe(true)
})
it("returns false for an unwritable path", () => {
_resetForTests()
process.env.SETTINGS_FILE = "/nonexistent-root-dir/settings.json"
expect(isSettingsWritable()).toBe(false)
})
})
describe("settings file on disk", () => {
it("writes valid JSON with restrictive permissions", () => {
saveSettings({ TEST_ADMIN_VAR: "secret" })
const filePath = process.env.SETTINGS_FILE!
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"))
expect(parsed).toEqual({
version: 1,
values: { TEST_ADMIN_VAR: "secret" },
})
const mode = fs.statSync(filePath).mode & 0o777
expect(mode).toBe(0o600)
})
})
+11 -1
View File
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it } from "vitest"
import { afterEach, beforeEach, describe, expect, it } from "vitest"
import { _resetForTests } from "@/lib/admin/settings"
import {
loadFlattenedServerModels,
type ServerModelsConfig,
@@ -7,11 +8,20 @@ import {
const ORIGINAL_ENV = { ...process.env }
beforeEach(() => {
// Isolate from any local data/settings.json (admin panel providers
// are merged into the server models config)
process.env.SETTINGS_FILE = "/nonexistent/settings.json"
_resetForTests()
})
afterEach(() => {
_resetForTests()
process.env.AI_PROVIDER = ORIGINAL_ENV.AI_PROVIDER
process.env.AI_MODEL = ORIGINAL_ENV.AI_MODEL
process.env.AI_MODELS_CONFIG_PATH = ORIGINAL_ENV.AI_MODELS_CONFIG_PATH
process.env.AI_MODELS_CONFIG = ORIGINAL_ENV.AI_MODELS_CONFIG
delete process.env.SETTINGS_FILE
})
describe("ServerModelsConfigSchema", () => {