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).
This commit is contained in:
dayuan.jiang
2026-06-10 23:15:18 +09:00
parent 9feab38a0b
commit e4a14c3628
12 changed files with 1392 additions and 701 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,71 @@
import { checkAdminAuth } from "@/lib/admin/auth"
import {
AdminProvidersSchema,
deriveEnvUpdates,
loadAdminProviders,
maskAdminProviders,
mergeSecrets,
validateAdminProviders,
} from "@/lib/admin/providers"
import { isSettingsWritable, saveSettings } from "@/lib/admin/settings"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
function payload() {
return {
writable: isSettingsWritable(),
providers: maskAdminProviders(loadAdminProviders()),
}
}
export async function GET(req: Request) {
const authError = checkAdminAuth(req)
if (authError) return authError
return Response.json(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 validationError = validateAdminProviders(merged)
if (validationError) {
return Response.json({ error: validationError }, { status: 400 })
}
saveSettings(deriveEnvUpdates(merged, stored))
return Response.json(payload())
}

View File

@@ -1,4 +1,4 @@
import { timingSafeEqual } from "crypto"
import { checkAdminAuth, maskSecret } from "@/lib/admin/auth"
import {
getEnvFallback,
getValueSource,
@@ -11,46 +11,10 @@ import {
SETTINGS_REGISTRY,
type SettingDef,
} from "@/lib/admin/settings-registry"
import { ServerModelsConfigSchema } from "@/lib/server-model-config"
// Zod schemas for json-type settings (kept here, server-side only — the
// registry is imported by the client and must stay free of server deps)
const JSON_VALIDATORS: Record<string, typeof ServerModelsConfigSchema> = {
AI_MODELS_CONFIG: ServerModelsConfigSchema,
}
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
function checkAuth(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
}
function maskSecret(value: string): { isSet: true; hint: string } {
return {
isSet: true,
hint: value.length > 8 ? `${value.slice(-4)}` : "••••",
}
}
function serializeSettings() {
const fileValues = loadSettings()
return SETTINGS_REGISTRY.map((def) => {
@@ -65,7 +29,7 @@ function serializeSettings() {
}
export async function GET(req: Request) {
const authError = checkAuth(req)
const authError = checkAdminAuth(req)
if (authError) return authError
return Response.json({
@@ -93,29 +57,13 @@ function validateValue(def: SettingDef, value: string): string | null {
return def.options?.includes(value)
? null
: `Must be one of: ${def.options?.join(", ")}`
case "json": {
let parsed: unknown
try {
parsed = JSON.parse(value)
} catch {
return "Invalid JSON"
}
const schema = JSON_VALIDATORS[def.key]
if (schema) {
const result = schema.safeParse(parsed)
if (!result.success) {
return `Invalid value: ${result.error.issues[0]?.message ?? "schema mismatch"}`
}
}
return null
}
default:
return null
}
}
export async function PUT(req: Request) {
const authError = checkAuth(req)
const authError = checkAdminAuth(req)
if (authError) return authError
if (!isSettingsWritable()) {

View File

@@ -0,0 +1,46 @@
import { POST as validateModel } from "@/app/api/validate-model/route"
import { checkAdminAuth } from "@/lib/admin/auth"
import { loadAdminProviders } from "@/lib/admin/providers"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
// Test a stored admin provider's model. The client only has masked
// secrets, so credentials are filled in server-side from settings.json
// and passed to the existing validate-model handler.
export async function POST(req: Request) {
const authError = checkAdminAuth(req)
if (authError) return authError
let body: { providerId?: string; modelId?: string }
try {
body = await req.json()
} catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
}
const stored = loadAdminProviders().find((p) => p.id === body.providerId)
if (!stored || !body.modelId) {
return Response.json(
{ valid: false, error: "Unknown provider or model" },
{ status: 400 },
)
}
return validateModel(
new Request(new URL("/api/validate-model", req.url), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: stored.provider,
apiKey: stored.apiKey,
baseUrl: stored.baseUrl,
modelId: body.modelId,
awsAccessKeyId: stored.awsAccessKeyId,
awsSecretAccessKey: stored.awsSecretAccessKey,
awsRegion: stored.awsRegion,
vertexApiKey: stored.vertexApiKey,
}),
}),
)
}