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

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

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

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

@@ -0,0 +1,276 @@
import { z } from "zod"
import {
ProviderNameSchema,
type ServerModelsConfig,
} from "@/lib/server-model-config"
import { 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. The list itself is stored in settings.json
// under ADMIN_PROVIDERS; on save we derive AI_MODELS_CONFIG plus the
// credential env vars so the existing runtime picks everything up unchanged.
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)
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
// Default credential env vars per provider (first instance). Later
// instances of the same provider get a _2/_3 suffix wired up via
// apiKeyEnv/baseUrlEnv in the generated AI_MODELS_CONFIG.
// bedrock/vertexai use fixed env vars the runtime reads directly,
// so only one instance of each can carry server credentials.
const CRED_ENV: Partial<Record<ProviderName, { key: string; url: string }>> = {
openai: { key: "OPENAI_API_KEY", url: "OPENAI_BASE_URL" },
anthropic: { key: "ANTHROPIC_API_KEY", url: "ANTHROPIC_BASE_URL" },
google: { key: "GOOGLE_GENERATIVE_AI_API_KEY", url: "GOOGLE_BASE_URL" },
azure: { key: "AZURE_API_KEY", url: "AZURE_BASE_URL" },
ollama: { key: "OLLAMA_API_KEY", url: "OLLAMA_BASE_URL" },
openrouter: { key: "OPENROUTER_API_KEY", url: "OPENROUTER_BASE_URL" },
deepseek: { key: "DEEPSEEK_API_KEY", url: "DEEPSEEK_BASE_URL" },
siliconflow: { key: "SILICONFLOW_API_KEY", url: "SILICONFLOW_BASE_URL" },
sglang: { key: "SGLANG_API_KEY", url: "SGLANG_BASE_URL" },
gateway: { key: "AI_GATEWAY_API_KEY", url: "AI_GATEWAY_BASE_URL" },
doubao: { key: "DOUBAO_API_KEY", url: "DOUBAO_BASE_URL" },
modelscope: { key: "MODELSCOPE_API_KEY", url: "MODELSCOPE_BASE_URL" },
glm: { key: "GLM_API_KEY", url: "GLM_BASE_URL" },
qwen: { key: "QWEN_API_KEY", url: "QWEN_BASE_URL" },
kimi: { key: "KIMI_API_KEY", url: "KIMI_BASE_URL" },
qiniu: { key: "QINIU_API_KEY", url: "QINIU_BASE_URL" },
minimax: { key: "MINIMAX_API_KEY", url: "MINIMAX_BASE_URL" },
novita: { key: "NOVITA_API_KEY", url: "NOVITA_BASE_URL" },
}
export function loadAdminProviders(): StoredAdminProvider[] {
const raw = loadSettings()[ADMIN_PROVIDERS_KEY]
if (!raw) return []
try {
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : []
} 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
})
}
export function validateAdminProviders(
list: StoredAdminProvider[],
): string | null {
for (const single of ["bedrock", "vertexai"] as const) {
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).`
}
}
const names = list.map((p) => displayName(p))
if (new Set(names).size !== names.length) {
return "Provider display names must be unique."
}
if (list.filter((p) => p.isDefault).length > 1) {
return "Only one provider can be the default."
}
return null
}
function displayName(p: StoredAdminProvider): string {
return p.name?.trim() || PROVIDER_INFO[p.provider].label
}
// Env var names for instance `index` (0-based) of a provider
function credEnvNames(
provider: ProviderName,
index: number,
): { key?: string; url?: string } {
const base = CRED_ENV[provider]
if (!base) return {}
if (index === 0) return base
return { key: `${base.key}_${index + 1}`, url: `${base.url}_${index + 1}` }
}
// All env updates derived from the provider list: credential vars,
// AI_MODELS_CONFIG, and AI_PROVIDER/AI_MODEL for the default model.
// 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 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.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
}
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
if (p.models.length > 0) {
config.providers.push({
name: displayName(p),
provider: p.provider,
models: p.models,
// First instances use the default env vars the runtime
// already falls back to; only extras need explicit wiring
...(index > 0 && env.key && p.apiKey
? { apiKeyEnv: env.key }
: {}),
...(index > 0 && env.url && p.baseUrl
? { baseUrlEnv: env.url }
: {}),
...(p.isDefault ? { default: true } : {}),
})
}
}
if (config.providers.length > 0) {
updates[ADMIN_PROVIDERS_KEY] = JSON.stringify(list)
updates.AI_MODELS_CONFIG = JSON.stringify(config)
const defaultEntry =
list.find((p) => p.isDefault && p.models.length > 0) ??
list.find((p) => p.models.length > 0)
if (defaultEntry) {
updates.AI_PROVIDER = defaultEntry.provider
updates.AI_MODEL = defaultEntry.models[0]
}
} else if (list.length > 0) {
// Providers configured but no models yet — keep the list only
updates[ADMIN_PROVIDERS_KEY] = JSON.stringify(list)
} else {
updates[ADMIN_PROVIDERS_KEY] = null
}
return updates
}
// Every settings key the panel may have written for a given list
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")
}
const env = credEnvNames(p.provider, index)
if (env.key) keys.add(env.key)
if (env.url) keys.add(env.url)
}
return [...keys]
}

View File

@@ -1,20 +1,16 @@
// Declarative registry of all env vars editable in the admin panel.
// 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)
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
export type SettingType =
| "string"
| "secret"
| "number"
| "boolean"
| "enum"
| "json"
export type SettingType = "string" | "secret" | "number" | "boolean" | "enum"
export interface SettingDef {
key: string
@@ -28,9 +24,6 @@ export interface SettingDef {
placeholder?: string
// Value is only picked up at process start (module-load readers)
restartRequired?: boolean
// Provider this setting belongs to; rendered inside that provider's
// collapsible in the Provider Credentials group
provider?: ProviderName
}
export interface SettingGroup {
@@ -44,20 +37,9 @@ export interface SettingGroup {
export const SETTING_GROUPS: SettingGroup[] = [
{
id: "general",
title: "General",
description: "Default AI provider and model used by the server.",
},
{
id: "providers",
title: "Provider Credentials",
description:
"API keys, endpoints, and tuning for each supported provider.",
},
{
id: "models",
title: "Model Behavior",
description: "Generation parameters and the multi-model registry.",
id: "generation",
title: "Generation",
description: "Output parameters applied to all chat requests.",
},
{
id: "access",
@@ -84,363 +66,11 @@ export const SETTING_GROUPS: SettingGroup[] = [
},
]
const PROVIDER_OPTIONS = Object.keys(PROVIDER_INFO)
// Providers with just {PREFIX}_API_KEY + {PREFIX}_BASE_URL env vars.
// Labels and URL placeholders come from PROVIDER_INFO.
const SIMPLE_PROVIDERS: Array<{ provider: ProviderName; prefix?: string }> = [
{ provider: "openrouter" },
{ provider: "deepseek" },
{ provider: "siliconflow" },
{ provider: "sglang" },
{ provider: "gateway", prefix: "AI_GATEWAY" },
{ provider: "doubao" },
{ provider: "modelscope" },
{ provider: "glm" },
{ provider: "qwen" },
{ provider: "kimi" },
{ provider: "qiniu" },
{ provider: "minimax" },
{ provider: "novita" },
]
function simpleProviderSettings(): SettingDef[] {
return SIMPLE_PROVIDERS.flatMap(
({ provider, prefix = provider.toUpperCase() }) => [
{
key: `${prefix}_API_KEY`,
group: "providers",
provider,
type: "secret" as const,
label: "API Key",
},
{
key: `${prefix}_BASE_URL`,
group: "providers",
provider,
type: "string" as const,
label: "Base URL",
placeholder: PROVIDER_INFO[provider].defaultBaseUrl,
},
],
)
}
export const SETTINGS_REGISTRY: SettingDef[] = [
// ── General ──────────────────────────────────────────────────────
{
key: "AI_PROVIDER",
group: "general",
type: "enum",
label: "AI Provider",
description: "Default provider for chat requests.",
options: PROVIDER_OPTIONS,
},
{
key: "AI_MODEL",
group: "general",
type: "string",
label: "AI Model",
description: "Model ID for the chosen provider.",
placeholder: "e.g. gpt-5.2 or global.anthropic.claude-sonnet-4-5…",
},
// ── Providers: AWS Bedrock ───────────────────────────────────────
{
key: "AWS_REGION",
group: "providers",
provider: "bedrock",
type: "string",
label: "AWS Region",
placeholder: "us-west-2",
},
{
key: "AWS_ACCESS_KEY_ID",
group: "providers",
provider: "bedrock",
type: "secret",
label: "Access Key ID",
},
{
key: "AWS_SECRET_ACCESS_KEY",
group: "providers",
provider: "bedrock",
type: "secret",
label: "Secret Access Key",
},
{
key: "BEDROCK_REASONING_BUDGET_TOKENS",
group: "providers",
provider: "bedrock",
type: "number",
label: "Reasoning Budget Tokens",
description: "Claude extended-thinking budget (102464000).",
min: 1024,
max: 64000,
},
{
key: "BEDROCK_REASONING_EFFORT",
group: "providers",
provider: "bedrock",
type: "enum",
label: "Reasoning Effort",
description: "For Nova models.",
options: ["low", "medium", "high"],
},
// ── Providers: OpenAI ────────────────────────────────────────────
{
key: "OPENAI_API_KEY",
group: "providers",
provider: "openai",
type: "secret",
label: "API Key",
},
{
key: "OPENAI_BASE_URL",
group: "providers",
provider: "openai",
type: "string",
label: "Base URL",
description: "Custom OpenAI-compatible endpoint.",
placeholder: PROVIDER_INFO.openai.defaultBaseUrl,
},
{
key: "OPENAI_REASONING_EFFORT",
group: "providers",
provider: "openai",
type: "enum",
label: "Reasoning Effort",
options: ["minimal", "low", "medium", "high"],
},
{
key: "OPENAI_REASONING_SUMMARY",
group: "providers",
provider: "openai",
type: "enum",
label: "Reasoning Summary",
options: ["none", "brief", "detailed"],
},
// ── Providers: Anthropic ─────────────────────────────────────────
{
key: "ANTHROPIC_API_KEY",
group: "providers",
provider: "anthropic",
type: "secret",
label: "API Key",
description: "Sent as x-api-key header.",
},
{
key: "ANTHROPIC_AUTH_TOKEN",
group: "providers",
provider: "anthropic",
type: "secret",
label: "Auth Token",
description:
"Alternative to the API key; sent as Authorization: Bearer.",
},
{
key: "ANTHROPIC_BASE_URL",
group: "providers",
provider: "anthropic",
type: "string",
label: "Base URL",
placeholder: PROVIDER_INFO.anthropic.defaultBaseUrl,
},
{
key: "ANTHROPIC_THINKING_TYPE",
group: "providers",
provider: "anthropic",
type: "enum",
label: "Extended Thinking",
options: ["enabled"],
},
{
key: "ANTHROPIC_THINKING_BUDGET_TOKENS",
group: "providers",
provider: "anthropic",
type: "number",
label: "Thinking Budget Tokens",
min: 1024,
max: 64000,
},
// ── Providers: Google ────────────────────────────────────────────
{
key: "GOOGLE_GENERATIVE_AI_API_KEY",
group: "providers",
provider: "google",
type: "secret",
label: "API Key",
},
{
key: "GOOGLE_BASE_URL",
group: "providers",
provider: "google",
type: "string",
label: "Base URL",
placeholder: PROVIDER_INFO.google.defaultBaseUrl,
},
{
key: "GOOGLE_THINKING_BUDGET",
group: "providers",
provider: "google",
type: "number",
label: "Thinking Budget (Gemini 2.5)",
min: 1024,
max: 100000,
},
{
key: "GOOGLE_THINKING_LEVEL",
group: "providers",
provider: "google",
type: "enum",
label: "Thinking Level (Gemini 3)",
options: ["low", "high"],
},
{
key: "GOOGLE_REASONING_EFFORT",
group: "providers",
provider: "google",
type: "enum",
label: "Reasoning Effort",
options: ["low", "medium", "high"],
},
{
key: "GOOGLE_CANDIDATE_COUNT",
group: "providers",
provider: "google",
type: "number",
label: "Candidate Count",
min: 1,
max: 8,
},
{
key: "GOOGLE_TOP_K",
group: "providers",
provider: "google",
type: "number",
label: "Top K",
min: 1,
max: 100,
},
{
key: "GOOGLE_TOP_P",
group: "providers",
provider: "google",
type: "number",
label: "Top P",
min: 0,
max: 1,
},
// ── Providers: Vertex AI ─────────────────────────────────────────
{
key: "GOOGLE_VERTEX_API_KEY",
group: "providers",
provider: "vertexai",
type: "secret",
label: "API Key",
description: "Express Mode API key.",
},
{
key: "GOOGLE_VERTEX_BASE_URL",
group: "providers",
provider: "vertexai",
type: "string",
label: "Base URL",
},
{
key: "GOOGLE_VERTEX_THINKING_BUDGET",
group: "providers",
provider: "vertexai",
type: "number",
label: "Thinking Budget (Gemini 2.5)",
min: 1024,
max: 100000,
},
{
key: "GOOGLE_VERTEX_THINKING_LEVEL",
group: "providers",
provider: "vertexai",
type: "enum",
label: "Thinking Level (Gemini 3)",
options: ["minimal", "low", "medium", "high"],
},
// ── Providers: Azure OpenAI ──────────────────────────────────────
{
key: "AZURE_API_KEY",
group: "providers",
provider: "azure",
type: "secret",
label: "API Key",
},
{
key: "AZURE_RESOURCE_NAME",
group: "providers",
provider: "azure",
type: "string",
label: "Resource Name",
description: "Endpoint becomes https://{name}.openai.azure.com.",
},
{
key: "AZURE_BASE_URL",
group: "providers",
provider: "azure",
type: "string",
label: "Base URL",
description: "Alternative to resource name; takes precedence.",
},
{
key: "AZURE_REASONING_EFFORT",
group: "providers",
provider: "azure",
type: "enum",
label: "Reasoning Effort",
options: ["low", "medium", "high"],
},
{
key: "AZURE_REASONING_SUMMARY",
group: "providers",
provider: "azure",
type: "enum",
label: "Reasoning Summary",
options: ["none", "brief", "detailed"],
},
// ── Providers: Ollama ────────────────────────────────────────────
{
key: "OLLAMA_BASE_URL",
group: "providers",
provider: "ollama",
type: "string",
label: "Base URL",
placeholder: PROVIDER_INFO.ollama.defaultBaseUrl,
},
{
key: "OLLAMA_API_KEY",
group: "providers",
provider: "ollama",
type: "secret",
label: "API Key",
description: "For Ollama Cloud or authenticated remote instances.",
},
{
key: "OLLAMA_ENABLE_THINKING",
group: "providers",
provider: "ollama",
type: "boolean",
label: "Enable Thinking",
},
...simpleProviderSettings(),
// ── Model Behavior ───────────────────────────────────────────────
// ── Generation ───────────────────────────────────────────────────
{
key: "TEMPERATURE",
group: "models",
group: "generation",
type: "number",
label: "Temperature",
description:
@@ -450,27 +80,11 @@ export const SETTINGS_REGISTRY: SettingDef[] = [
},
{
key: "MAX_OUTPUT_TOKENS",
group: "models",
group: "generation",
type: "number",
label: "Max Output Tokens",
min: 1,
},
{
key: "AI_MODELS_CONFIG",
group: "models",
type: "json",
label: "Multi-Model Registry (JSON)",
description:
'Server model list shown to all users. Schema: {"providers":[{"name":"…","provider":"openai","models":["…"]}]}.',
},
{
key: "AI_MODELS_CONFIG_PATH",
group: "models",
type: "string",
label: "Model Registry File Path",
description: "Used only when the JSON registry above is empty.",
placeholder: "./ai-models.json",
},
// ── Access Control ───────────────────────────────────────────────
{
@@ -608,12 +222,3 @@ export const SETTINGS_BY_GROUP: Map<string, SettingDef[]> = new Map(
SETTINGS_REGISTRY.filter((d) => d.group === g.id),
]),
)
// Provider Credentials group, keyed by provider in registry order
export const PROVIDER_SUBGROUPS: Map<ProviderName, SettingDef[]> = new Map()
for (const def of SETTINGS_REGISTRY) {
if (!def.provider) continue
const list = PROVIDER_SUBGROUPS.get(def.provider) ?? []
list.push(def)
PROVIDER_SUBGROUPS.set(def.provider, list)
}