From e4a14c3628a36314e9cb3ea5abac3c44803827c1 Mon Sep 17 00:00:00 2001
From: "dayuan.jiang"
Date: Wed, 10 Jun 2026 23:15:18 +0900
Subject: [PATCH] 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).
---
README.md | 5 +-
app/[lang]/admin/page.tsx | 987 ++++++++++++++++++++++-------
app/api/admin/providers/route.ts | 71 +++
app/api/admin/settings/route.ts | 58 +-
app/api/admin/test-model/route.ts | 46 ++
docs/cn/README_CN.md | 5 +-
docs/ja/README_JA.md | 5 +-
lib/admin/auth.ts | 37 ++
lib/admin/providers.ts | 276 ++++++++
lib/admin/settings-registry.ts | 419 +-----------
lib/ai-providers.ts | 2 +-
tests/unit/admin-providers.test.ts | 182 ++++++
12 files changed, 1392 insertions(+), 701 deletions(-)
create mode 100644 app/api/admin/providers/route.ts
create mode 100644 app/api/admin/test-model/route.ts
create mode 100644 lib/admin/auth.ts
create mode 100644 lib/admin/providers.ts
create mode 100644 tests/unit/admin-providers.test.ts
diff --git a/README.md b/README.md
index 7dd607f..fe82103 100644
--- a/README.md
+++ b/README.md
@@ -228,11 +228,12 @@ Administrators can configure multiple server-side models that are available to a
### Admin Panel
-Instead of hand-editing `.env`, you can manage most server settings (provider keys, model behavior, access codes, quota, features) in a web admin panel:
+Instead of hand-editing `.env`, you can manage server settings in a web admin panel:
1. Set the `ADMIN_PASSWORD` environment variable (leave unset to disable the panel).
2. Visit `/admin` and sign in.
-3. 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").
+3. In the Models section, add providers with their API keys and model lists — the same UI as the in-app model settings. Saved models become server-side models available to all users, and credentials plus `AI_MODELS_CONFIG` are generated automatically.
+4. Other sections cover 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.
diff --git a/app/[lang]/admin/page.tsx b/app/[lang]/admin/page.tsx
index 38f0cfc..091bd57 100644
--- a/app/[lang]/admin/page.tsx
+++ b/app/[lang]/admin/page.tsx
@@ -1,24 +1,33 @@
"use client"
import {
+ AlertCircle,
AlertTriangle,
Check,
- ChevronRight,
Eye,
EyeOff,
Loader2,
LockKeyhole,
- RotateCcw,
+ Plus,
ShieldCheck,
+ Star,
+ Trash2,
+ X,
+ Zap,
} from "lucide-react"
import { useCallback, useEffect, useState } from "react"
import { ProviderLogo } from "@/components/provider-logo"
-import { Button } from "@/components/ui/button"
import {
- Collapsible,
- CollapsibleContent,
- CollapsibleTrigger,
-} from "@/components/ui/collapsible"
+ 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 {
@@ -29,35 +38,29 @@ import {
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
-import { Textarea } from "@/components/ui/textarea"
import {
- PROVIDER_SUBGROUPS,
SETTING_GROUPS,
SETTINGS_BY_GROUP,
type SettingDef,
} from "@/lib/admin/settings-registry"
import { getApiEndpoint } from "@/lib/base-path"
-import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
+import {
+ PROVIDER_INFO,
+ type ProviderName,
+ SUGGESTED_MODELS,
+} from "@/lib/types/model-config"
import { cn } from "@/lib/utils"
const SESSION_PASSWORD_KEY = "next-ai-draw-io-admin-password"
-async function fetchSettings(pw: string) {
- const res = await fetch(getApiEndpoint("/api/admin/settings"), {
- headers: { "x-admin-password": pw },
- })
- if (!res.ok) {
- const data = await res.json().catch(() => ({}))
- throw new Error(data.error || `Request failed (${res.status})`)
- }
- return res.json() as Promise<{
- writable: boolean
- settings: SettingState[]
- }>
-}
+// ── Shared types ─────────────────────────────────────────────────────
type SecretValue = { isSet: true; hint: string }
+function isSecretValue(v: unknown): v is SecretValue {
+ return typeof v === "object" && v !== null && "isSet" in v
+}
+
interface SettingState {
key: string
source: "file" | "env" | "default"
@@ -66,15 +69,45 @@ interface SettingState {
type SettingsMap = Record
-function isSecretValue(v: unknown): v is SecretValue {
- return typeof v === "object" && v !== null && "isSet" in v
-}
-
// Editable text of a saved setting; secrets have none (write-only)
function savedTextOf(state: SettingState | undefined): string {
return state && !isSecretValue(state.value) ? (state.value ?? "") : ""
}
+// Admin provider in client state. Secret fields hold either a masked
+// marker (unchanged) or a plaintext string (new value).
+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
+}
+
+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
+}
+
+// ── Small shared UI bits ─────────────────────────────────────────────
+
function SourceChip({ source }: { source: "file" | "env" | "default" }) {
if (source === "default") return null
return (
@@ -104,6 +137,56 @@ function RestartBadge() {
)
}
+// Secret input: shows masked hint as placeholder, typing replaces
+function SecretInput({
+ id,
+ value,
+ disabled,
+ onChange,
+}: {
+ id: string
+ value: string | SecretValue | undefined
+ disabled?: boolean
+ onChange: (value: string) => void
+}) {
+ const [show, setShow] = useState(false)
+ const text = typeof value === "string" ? value : ""
+ const placeholder = isSecretValue(value)
+ ? `Saved (${value.hint}) — type to replace`
+ : "Not set"
+ return (
+
+ onChange(e.target.value)}
+ />
+
+
+ )
+}
+
+// ── General settings field (registry-driven) ─────────────────────────
+
function SettingField({
def,
state,
@@ -119,15 +202,10 @@ function SettingField({
disabled: boolean
onChange: (value: string | null) => void
}) {
- const [showSecret, setShowSecret] = useState(false)
const isDirty = pendingValue !== undefined
const source = state?.source ?? "default"
-
const currentValue = isDirty ? (pendingValue ?? "") : savedTextOf(state)
-
- const secretIsSet = state ? isSecretValue(state.value) : false
- const secretHint =
- state && isSecretValue(state.value) ? state.value.hint : ""
+ const secretState = state && isSecretValue(state.value) ? state.value : null
const inputId = `setting-${def.key}`
const errorId = `${inputId}-error`
@@ -166,55 +244,19 @@ function SettingField({
)
break
- case "json":
- control = (
-
{dirtyCount > 0 && (
@@ -820,6 +1342,7 @@ export default function AdminPage() {
onClick={() => {
setPending({})
setErrors({})
+ setProviders(JSON.parse(savedProviders))
}}
>
Discard
diff --git a/app/api/admin/providers/route.ts b/app/api/admin/providers/route.ts
new file mode 100644
index 0000000..d93e5cc
--- /dev/null
+++ b/app/api/admin/providers/route.ts
@@ -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())
+}
diff --git a/app/api/admin/settings/route.ts b/app/api/admin/settings/route.ts
index 71825fc..f63435b 100644
--- a/app/api/admin/settings/route.ts
+++ b/app/api/admin/settings/route.ts
@@ -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 = {
- 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()) {
diff --git a/app/api/admin/test-model/route.ts b/app/api/admin/test-model/route.ts
new file mode 100644
index 0000000..9b93c5f
--- /dev/null
+++ b/app/api/admin/test-model/route.ts
@@ -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,
+ }),
+ }),
+ )
+}
diff --git a/docs/cn/README_CN.md b/docs/cn/README_CN.md
index 9ed7fcd..e94af80 100644
--- a/docs/cn/README_CN.md
+++ b/docs/cn/README_CN.md
@@ -224,11 +224,12 @@ npm run dev
### 管理面板
-无需手动编辑 `.env`,您可以在 Web 管理面板中管理大部分服务端设置(提供商密钥、模型行为、访问码、配额、功能开关):
+无需手动编辑 `.env`,您可以在 Web 管理面板中管理服务端设置:
1. 设置 `ADMIN_PASSWORD` 环境变量(不设置则面板禁用)。
2. 访问 `/admin` 并登录。
-3. 保存的设置会写入 `data/settings.json` 并立即生效,无需重启(少数设置如 Langfuse 和 DynamoDB 标记为"需要重启")。
+3. 在 Models 区块中添加提供商及其 API Key 和模型列表——交互与应用内的模型设置相同。保存后这些模型成为所有用户可用的服务端模型,凭证和 `AI_MODELS_CONFIG` 会自动生成。
+4. 其余区块涵盖访问码、生成参数、功能开关、可观测性和配额。保存的设置会写入 `data/settings.json` 并立即生效,无需重启(少数设置如 Langfuse 和 DynamoDB 标记为"需要重启")。
优先级:面板中保存的设置覆盖环境变量,环境变量覆盖内置默认值。删除已保存的值会回退到环境变量。
diff --git a/docs/ja/README_JA.md b/docs/ja/README_JA.md
index 9cd0633..35d3881 100644
--- a/docs/ja/README_JA.md
+++ b/docs/ja/README_JA.md
@@ -223,11 +223,12 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
### 管理パネル
-`.env` を手動で編集する代わりに、Web 管理パネルでほとんどのサーバー設定(プロバイダーキー、モデル動作、アクセスコード、クォータ、機能)を管理できます:
+`.env` を手動で編集する代わりに、Web 管理パネルでサーバー設定を管理できます:
1. `ADMIN_PASSWORD` 環境変数を設定します(未設定の場合、パネルは無効になります)。
2. `/admin` にアクセスしてサインインします。
-3. 保存された設定は `data/settings.json` に書き込まれ、即座に反映されます — 再起動は不要です(Langfuse や DynamoDB など一部の設定は「再起動が必要」と表示されます)。
+3. Models セクションでプロバイダーの API キーとモデルリストを追加します — アプリ内のモデル設定と同じ UI です。保存するとそれらは全ユーザーが利用できるサーバーサイドモデルになり、認証情報と `AI_MODELS_CONFIG` は自動生成されます。
+4. その他のセクションはアクセスコード、生成パラメータ、機能、可観測性、クォータをカバーします。保存された設定は `data/settings.json` に書き込まれ、即座に反映されます — 再起動は不要です(Langfuse や DynamoDB など一部の設定は「再起動が必要」と表示されます)。
優先順位:パネルで保存された設定は環境変数を上書きし、環境変数は組み込みのデフォルト値を上書きします。保存した値を削除すると環境変数にフォールバックします。
diff --git a/lib/admin/auth.ts b/lib/admin/auth.ts
new file mode 100644
index 0000000..eb8373f
--- /dev/null
+++ b/lib/admin/auth.ts
@@ -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)}` : "••••",
+ }
+}
diff --git a/lib/admin/providers.ts b/lib/admin/providers.ts
new file mode 100644
index 0000000..da3dc95
--- /dev/null
+++ b/lib/admin/providers.ts
@@ -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
+
+// 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> = {
+ 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 {
+ const updates: Record = {}
+
+ // 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()
+
+ 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([
+ "AI_MODELS_CONFIG",
+ "AI_PROVIDER",
+ "AI_MODEL",
+ ])
+ const indexByProvider = new Map()
+ 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]
+}
diff --git a/lib/admin/settings-registry.ts b/lib/admin/settings-registry.ts
index af71b7f..f3ee8cd 100644
--- a/lib/admin/settings-registry.ts
+++ b/lib/admin/settings-registry.ts
@@ -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 (1024–64000).",
- 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 = new Map(
SETTINGS_REGISTRY.filter((d) => d.group === g.id),
]),
)
-
-// Provider Credentials group, keyed by provider in registry order
-export const PROVIDER_SUBGROUPS: Map = new Map()
-for (const def of SETTINGS_REGISTRY) {
- if (!def.provider) continue
- const list = PROVIDER_SUBGROUPS.get(def.provider) ?? []
- list.push(def)
- PROVIDER_SUBGROUPS.set(def.provider, list)
-}
diff --git a/lib/ai-providers.ts b/lib/ai-providers.ts
index 9cdd90f..d352ab2 100644
--- a/lib/ai-providers.ts
+++ b/lib/ai-providers.ts
@@ -537,7 +537,7 @@ function buildProviderOptions(
}
// Map of provider to required environment variable
-const PROVIDER_ENV_VARS: Record = {
+export const PROVIDER_ENV_VARS: Record = {
bedrock: null, // AWS SDK auto-uses IAM role on AWS, or env vars locally
openai: "OPENAI_API_KEY",
anthropic: "ANTHROPIC_API_KEY",
diff --git a/tests/unit/admin-providers.test.ts b/tests/unit/admin-providers.test.ts
new file mode 100644
index 0000000..509159b
--- /dev/null
+++ b/tests/unit/admin-providers.test.ts
@@ -0,0 +1,182 @@
+import fs from "fs"
+import os from "os"
+import path from "path"
+import { afterEach, beforeEach, describe, expect, it } from "vitest"
+import {
+ deriveEnvUpdates,
+ mergeSecrets,
+ type StoredAdminProvider,
+ validateAdminProviders,
+} from "@/lib/admin/providers"
+import { _resetForTests } from "@/lib/admin/settings"
+
+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
+ fs.rmSync(tmpDir, { recursive: true, force: true })
+})
+
+function provider(
+ overrides: Partial = {},
+): StoredAdminProvider {
+ return {
+ id: "p1",
+ provider: "openai",
+ apiKey: "sk-test",
+ models: ["gpt-5.2"],
+ ...overrides,
+ }
+}
+
+describe("deriveEnvUpdates", () => {
+ it("writes credential env vars and AI_MODELS_CONFIG", () => {
+ const updates = deriveEnvUpdates([provider()], [])
+ expect(updates.OPENAI_API_KEY).toBe("sk-test")
+ const config = JSON.parse(updates.AI_MODELS_CONFIG as string)
+ expect(config.providers).toHaveLength(1)
+ expect(config.providers[0].models).toEqual(["gpt-5.2"])
+ expect(updates.AI_PROVIDER).toBe("openai")
+ expect(updates.AI_MODEL).toBe("gpt-5.2")
+ })
+
+ 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.OPENAI_API_KEY).toBe("sk-test")
+ expect(updates.OPENAI_API_KEY_2).toBe("sk-second")
+ const config = JSON.parse(updates.AI_MODELS_CONFIG as string)
+ expect(config.providers[1].apiKeyEnv).toBe("OPENAI_API_KEY_2")
+ })
+
+ 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.OPENAI_API_KEY).toBeNull()
+ expect(updates.AI_MODELS_CONFIG).toBeNull()
+ expect(updates.ADMIN_PROVIDERS).toBeNull()
+ })
+
+ it("respects the default flag for AI_PROVIDER/AI_MODEL", () => {
+ 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")
+ const config = JSON.parse(updates.AI_MODELS_CONFIG as string)
+ expect(config.providers[1].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("validateAdminProviders", () => {
+ 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()
+ })
+})