Files
next-ai-draw-io/lib/types/model-config.ts
Dayuan Jiang 449e4c4e26 feat: add file-based admin settings panel at /admin (#866)
* feat: add file-based admin settings panel at /admin

Settings saved in the panel are written to data/settings.json and
overlaid onto process.env, taking precedence over environment
variables and applying immediately without restart. Enable by setting
ADMIN_PASSWORD; on serverless platforms without persistent disk the
panel degrades to read-only.

* polish: admin panel UI improvements

- Provider logos in credential rows (shared ProviderLogo component,
  extracted from model-config-dialog)
- Scroll-spy active state in the sidebar nav
- Green success state in the save bar that clears after a few seconds
- Wider content column (max-w-6xl) for less wasted space on desktop

* polish: admin panel section toggles and reorder

- Move Quota & Rate Limits to the end of the settings page
- Add enable switches to Observability and Quota sections; default off
  with fields grayed out, auto-on when any field is already configured

* polish: make section enable switch more visible

Wrap the switch in a labeled pill ('Enabled'/'Disabled') with border
and background so the off state is clearly visible.

* refactor: derive admin registry from PROVIDER_INFO, simplify page state

- Provider options, labels, and base-URL placeholders now come from
  PROVIDER_INFO instead of hand-copied lists (fixes SiliconFlow .com/.cn
  placeholder drift; panel names now match the model-config dialog)
- Replace free-text subgroup strings + SUBGROUP_PROVIDERS reverse map
  with a typed provider field on SettingDef
- Precompute SETTINGS_BY_GROUP and PROVIDER_SUBGROUPS at module level
- Merge justSaved into saveMessage, drop unused mainRef, hoist
  fetchSettings out of the component, dedupe savedText logic
- Serialize from SETTINGS_REGISTRY directly; json validators in a map
  instead of a hardcoded key check
- Make allowPrivateUrls a function so ALLOW_PRIVATE_URLS edits in the
  admin panel apply without restart

* feat: graphical model management in admin panel

Replace the provider credential fields and raw AI_MODELS_CONFIG JSON
textarea with a Models section mirroring the in-app model settings UI:
provider instance list with logos, credential fields per provider type,
model add/remove with suggestions, per-model connectivity test, and a
default-provider star.

On save the server derives everything the runtime needs into
settings.json: credential env vars (with _2 suffixes for multiple
instances of one provider), AI_MODELS_CONFIG, and AI_PROVIDER/AI_MODEL
for the default. Secrets round-trip as masked markers and are never
sent back to the browser. The general settings registry now only
covers non-provider settings (generation, access, features,
observability, quota).

* fix: allow testing unsaved providers in admin panel

The test button previously looked up credentials by providerId in the
saved settings, so testing a newly added (unsaved) provider failed with
'Unknown provider or model'. The test endpoint now accepts the client's
current provider state; newly typed secrets are used as-is and masked
markers are resolved against the stored values, so testing works both
before and after saving.

* fix: merge env AI_MODELS_CONFIG with admin panel providers

Previously, saving in the admin panel wrote a complete AI_MODELS_CONFIG
into settings.json, which (by overlay precedence) replaced any config
from .env or ai-models.json — admins lost their env-configured models.

The panel no longer writes AI_MODELS_CONFIG. Instead its providers are
merged with the env baseline at read time in loadRawServerModelsConfig,
and panel credentials go to ADMIN_-prefixed env vars wired up via
apiKeyEnv/baseUrlEnv so they never shadow standard vars. Env-based
providers now appear read-only in the panel, name clashes are rejected,
and a panel default overrides the env default. data/ is now gitignored.

* fix: block global-credential providers already managed via env

Bedrock, Vertex AI, and Ollama credentials live in fixed env vars with
no apiKeyEnv redirection, so a panel instance of one of these would
silently override the credentials that env-configured models rely on.
The API now rejects saving such a provider when the env config already
uses that type, and the Add Provider dropdown disables it with a
'managed via env' note.

* fix: address admin panel review findings

- Security: test-model no longer resolves a stored secret when the
  request's baseUrl/provider differs from the stored entry, closing a
  path where a tampered baseUrl could exfiltrate a saved key
- Save failures are now visible: the save bar shows the error in red
  (was masked by the persistent 'Unsaved changes' text), and per-field
  validation errors from the settings API are surfaced under each field
- The Observability/Quota enable switch is now real: toggling off stages
  deletion of the group's saved values, and the toggle no longer snaps
  back to Enabled after saving
- Env provider's default star is hidden when a panel provider is the
  active default (no more double star)
- Clearing a credential field reverts to the stored value instead of
  silently deleting it; an explicit X button removes a stored secret
- Form inputs are disabled during an in-flight save

* refactor(admin): split 1549-line admin page into focused modules

Extract admin-shared.ts (types + fetch helper), setting-field.tsx
(registry-driven fields), and models-section.tsx (provider/model
manager) from page.tsx. Pure mechanical move, no behavior change.

* feat(admin): share credential fields with user dialog and localize panel

Extract ProviderCredentialsFields (display name + per-provider
credential inputs) used by both the user ModelConfigDialog and the
admin Models panel; secret input passed via renderSecret (plaintext
vs masked), test button via footer slot. Add full i18n for the admin
panel across en/zh/ja/zh-Hant, reusing modelConfig.* for shared parts.

* fix(admin): address Copilot review findings

- Reflect built-in defaults for boolean settings (ALLOW_PRIVATE_URLS
  defaults on) and allow clearing a saved boolean back to default,
  so the SSRF toggle matches actual runtime behavior.
- Harden JSON loading: filter settings values to strings only, and
  schema-validate stored ADMIN_PROVIDERS entries, dropping malformed
  ones instead of letting them reach runtime code.
- Set beforeunload returnValue so the unsaved-changes prompt shows in
  all browsers; reject non-finite numbers in settings validation.
- Fix README/CN/JA docs that claimed the panel auto-generates
  AI_MODELS_CONFIG (providers are merged at read time, not written).
- Add unit tests for corrupted-file value filtering and provider
  schema validation.

* docs: move admin panel details to dedicated docs/{en,cn,ja}/admin-panel.md

The READMEs now carry a short blurb + link, matching the existing
per-topic docs (docker.md, ai-providers.md, ...). Removes the ~22-line
inline section and the duplicated data/settings.json mentions.

* fix(admin): address follow-up Copilot findings on the prior fixes

- loadAdminProviders now validates against a stored-shape schema where
  secrets are plain strings, so a hand-edited ADMIN_PROVIDERS holding an
  {isSet} marker is dropped instead of later crashing maskSecret().
- loadSettings guards against array values (typeof [] === 'object'),
  which would otherwise overlay numeric keys onto process.env.
- Admin SecretInput uses the bare id so the shared component's
  <Label htmlFor> stays associated (only one ProviderDetail mounts).
- Add tests: marker-secret rejection, array-values guard, bedrock
  multi-secret round-trip.
2026-06-15 00:40:35 +09:00

477 lines
13 KiB
TypeScript

// Types for multi-provider model configuration
export type ProviderName =
| "openai"
| "anthropic"
| "google"
| "vertexai"
| "azure"
| "bedrock"
| "ollama"
| "openrouter"
| "deepseek"
| "siliconflow"
| "sglang"
| "gateway"
| "edgeone"
| "doubao"
| "modelscope"
| "glm"
| "qwen"
| "qiniu"
| "kimi"
| "minimax"
| "novita"
// Individual model configuration
export interface ModelConfig {
id: string // UUID for this model
modelId: string // e.g., "gpt-4o", "claude-sonnet-4-5"
validated?: boolean // Has this model been validated
validationError?: string // Error message if validation failed
}
// Provider configuration
export interface ProviderConfig {
id: string // UUID for this provider config
provider: ProviderName
name?: string // Custom display name (e.g., "OpenAI Production")
apiKey: string
baseUrl?: string
// AWS Bedrock specific fields
awsAccessKeyId?: string
awsSecretAccessKey?: string
awsRegion?: string
awsSessionToken?: string // Optional, for temporary credentials
// Vertex AI specific fields
vertexApiKey?: string // Express Mode API key
models: ModelConfig[]
validated?: boolean // Has API key been validated
}
// The complete multi-model configuration
export interface MultiModelConfig {
version: 1
providers: ProviderConfig[]
selectedModelId?: string // Currently selected model's UUID
showUnvalidatedModels?: boolean // Show models that haven't been validated
}
// Flattened model for dropdown display
export interface FlattenedModel {
id: string // Model config UUID or synthetic server ID (e.g., "server:provider:modelId")
modelId: string // Actual model ID
provider: ProviderName
providerLabel: string // Provider display name
apiKey: string
baseUrl?: string
// AWS Bedrock specific fields
awsAccessKeyId?: string
awsSecretAccessKey?: string
awsRegion?: string
awsSessionToken?: string
// Vertex AI specific fields
vertexApiKey?: string // Express Mode API key
validated?: boolean // Has this model been validated
// Source of this model config: user-defined (client) or server-defined
source?: "user" | "server"
// Whether this model is the server default (matches AI_MODEL env var)
isDefault?: boolean
// Custom env var name(s) for server models
// Can be a single string or array of strings for load balancing
apiKeyEnv?: string | string[]
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",
anthropic: "anthropic",
google: "google",
azure: "azure",
bedrock: "amazon-bedrock",
openrouter: "openrouter",
deepseek: "deepseek",
siliconflow: "siliconflow",
sglang: "openai", // SGLang is OpenAI-compatible
gateway: "vercel",
edgeone: "tencent-cloud",
vertexai: "google",
doubao: "bytedance",
modelscope: "modelscope",
minimax: "minimax",
novita: "novita",
}
// Provider metadata
export const PROVIDER_INFO: Record<
ProviderName,
{ label: string; defaultBaseUrl?: string }
> = {
openai: {
label: "OpenAI",
defaultBaseUrl: "https://api.openai.com/v1",
},
anthropic: {
label: "Anthropic",
defaultBaseUrl: "https://api.anthropic.com/v1",
},
google: {
label: "Google",
defaultBaseUrl: "https://generativelanguage.googleapis.com/v1beta",
},
vertexai: { label: "Google Vertex AI" },
azure: {
label: "Azure OpenAI",
defaultBaseUrl: "https://your-resource.openai.azure.com/openai",
},
bedrock: { label: "Amazon Bedrock" },
ollama: {
label: "Ollama",
defaultBaseUrl: "https://ollama.com/api",
},
openrouter: {
label: "OpenRouter",
defaultBaseUrl: "https://openrouter.ai/api/v1",
},
deepseek: {
label: "DeepSeek",
defaultBaseUrl: "https://api.deepseek.com/v1",
},
siliconflow: {
label: "SiliconFlow",
defaultBaseUrl: "https://api.siliconflow.cn/v1",
},
sglang: {
label: "SGLang",
defaultBaseUrl: "http://127.0.0.1:8000/v1",
},
gateway: {
label: "AI Gateway",
defaultBaseUrl: "https://ai-gateway.vercel.sh/v1/ai",
},
edgeone: { label: "EdgeOne Pages" },
doubao: {
label: "Doubao (ByteDance)",
defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/v3",
},
modelscope: {
label: "ModelScope",
defaultBaseUrl: "https://api-inference.modelscope.cn/v1",
},
glm: {
label: "GLM (Zhipu)",
defaultBaseUrl: "https://open.bigmodel.cn/api/paas/v4",
},
qwen: {
label: "Qwen (Alibaba)",
defaultBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
},
qiniu: {
label: "Qiniu",
defaultBaseUrl: "https://api.qnaigc.com/v1",
},
kimi: {
label: "Kimi (Moonshot)",
defaultBaseUrl: "https://api.moonshot.cn/v1",
},
minimax: {
label: "MiniMax",
defaultBaseUrl: "https://api.minimaxi.com/anthropic",
},
novita: {
label: "Novita AI",
defaultBaseUrl: "https://api.novita.ai/openai",
},
}
// Suggested models per provider for quick add
export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
openai: [
"gpt-5.5-pro",
"gpt-5.5",
"gpt-5.4-pro",
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.4-nano",
"gpt-5-codex-mini",
"gpt-4.1",
"gpt-4.1-mini",
"gpt-4o",
"gpt-4o-mini",
],
anthropic: [
// Claude 4.8 / 4.7 / 4.6 series (latest, dateless pinned IDs)
"claude-opus-4-8",
"claude-sonnet-4-6",
"claude-haiku-4-5",
"claude-opus-4-7",
"claude-opus-4-6",
// Claude 4.5 series
"claude-sonnet-4-5-20250929",
"claude-opus-4-5-20251101",
// Claude 3.7 series
"claude-3-7-sonnet-20250219",
// Claude 3.5 series
"claude-3-5-sonnet-20241022",
"claude-3-5-haiku-20241022",
],
google: [
// Gemini 3 series
"gemini-3.1-pro",
"gemini-3.5-flash",
"gemini-3-flash",
"gemini-3.1-flash-lite",
// Gemini 2.5 series
"gemini-2.5-pro",
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
],
vertexai: [
// Gemini 3 series
"gemini-3.1-pro-preview",
"gemini-3.5-flash",
"gemini-3-flash-preview",
"gemini-3.1-flash-lite",
// Gemini 2.5 series
"gemini-2.5-pro",
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
],
azure: [
"gpt-5.5",
"gpt-5.4",
"gpt-5.1",
"gpt-5",
"gpt-5-mini",
"gpt-4.1",
"gpt-4o",
"gpt-4o-mini",
"o3",
"o4-mini",
],
bedrock: [
// Anthropic Claude
"anthropic.claude-opus-4-8",
"anthropic.claude-opus-4-7",
"anthropic.claude-sonnet-4-6",
"anthropic.claude-opus-4-6-v1",
"anthropic.claude-opus-4-5-20251101-v1:0",
"anthropic.claude-sonnet-4-5-20250929-v1:0",
"anthropic.claude-haiku-4-5-20251001-v1:0",
"anthropic.claude-opus-4-1-20250805-v1:0",
"anthropic.claude-opus-4-20250514-v1:0",
"anthropic.claude-sonnet-4-20250514-v1:0",
"anthropic.claude-3-5-haiku-20241022-v1:0",
// Amazon Nova
"amazon.nova-2-lite-v1:0",
"amazon.nova-premier-v1:0",
"amazon.nova-pro-v1:0",
"amazon.nova-lite-v1:0",
"amazon.nova-micro-v1:0",
// Meta Llama
"meta.llama4-maverick-17b-instruct-v1:0",
"meta.llama4-scout-17b-instruct-v1:0",
"meta.llama3-3-70b-instruct-v1:0",
// Mistral
"mistral.mistral-large-3-675b-instruct",
"mistral.pixtral-large-2502-v1:0",
],
openrouter: [
// Anthropic
"anthropic/claude-opus-4.8",
"anthropic/claude-sonnet-4.6",
"anthropic/claude-haiku-4.5",
// OpenAI
"openai/gpt-5.5",
"openai/gpt-5.4",
"openai/gpt-5.4-mini",
"openai/gpt-4o-mini",
// Google
"google/gemini-3.1-pro-preview",
"google/gemini-3.5-flash",
"google/gemini-2.5-flash-lite",
// xAI
"x-ai/grok-4.3",
// Meta Llama
"meta-llama/llama-4-maverick",
"meta-llama/llama-4-scout",
"meta-llama/llama-3.3-70b-instruct",
// DeepSeek
"deepseek/deepseek-v4-pro",
"deepseek/deepseek-v3.2",
// Qwen
"qwen/qwen3.7-max",
"qwen/qwen3-coder",
// MiniMax
"minimax/minimax-m3",
],
deepseek: [
"deepseek-v4-pro",
"deepseek-v4-flash",
"deepseek-chat",
"deepseek-reasoner",
],
siliconflow: [
// DeepSeek
"deepseek-ai/DeepSeek-V4-Pro",
"deepseek-ai/DeepSeek-V4-Flash",
"deepseek-ai/DeepSeek-V3.2",
// MiniMax
"MiniMaxAI/MiniMax-M3",
// Moonshot
"moonshotai/Kimi-K2.6",
// Z.ai
"zai-org/GLM-5",
// Qwen
"Qwen/Qwen3.6-35B-A3B",
"Qwen/Qwen3-Coder-480B-A35B-Instruct",
"Qwen/Qwen3-30B-A3B-Instruct-2507",
"Qwen/Qwen3-VL-32B-Instruct",
// OpenAI open-weights
"openai/gpt-oss-120b",
],
sglang: [
// SGLang is OpenAI-compatible, models depend on deployment
"default",
],
gateway: [
"openai/gpt-5.5",
"anthropic/claude-opus-4.7",
"google/gemini-3.1-pro-preview",
"xai/grok-4.3",
"anthropic/claude-sonnet-4.6",
"anthropic/claude-haiku-4.5",
"openai/gpt-5.4-mini",
],
edgeone: ["@tx/deepseek-ai/deepseek-v32"],
doubao: [
// ByteDance Doubao models (Volcengine Ark IDs use dash form)
"doubao-seed-2-0-pro-260215",
"doubao-seed-2-0-lite-260428",
"doubao-seed-2-0-mini-260428",
"doubao-seed-1-8-251228",
"doubao-seed-1-6-251015",
"doubao-seed-1-6-flash-250828",
"doubao-seed-1-6-vision-250815",
"doubao-1-5-pro-32k-250115",
"doubao-1-5-lite-32k-250115",
],
modelscope: [
// DeepSeek
"deepseek-ai/DeepSeek-V4-Pro",
"deepseek-ai/DeepSeek-V3.2",
"deepseek-ai/DeepSeek-R1-0528",
"deepseek-ai/DeepSeek-R1",
// Qwen
"Qwen/Qwen3-235B-A22B-Instruct-2507",
"Qwen/Qwen3-VL-235B-A22B-Instruct",
"Qwen/Qwen3-Coder-30B-A3B-Instruct",
"Qwen/Qwen3-32B",
"Qwen/Qwen2.5-72B-Instruct",
],
minimax: [
// MiniMax models (Anthropic-compatible API)
"MiniMax-M3",
"MiniMax-M2.7",
"MiniMax-M2.7-highspeed",
"MiniMax-M2.5",
],
novita: [
// Novita AI models (OpenAI-compatible API)
"minimax/minimax-m3",
"deepseek/deepseek-v4-pro",
"zai-org/glm-5.1",
"moonshotai/kimi-k2.6",
"deepseek/deepseek-v4-flash",
],
}
// Helper to generate UUID
export function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
// Create empty config
export function createEmptyConfig(): MultiModelConfig {
return {
version: 1,
providers: [],
selectedModelId: undefined,
}
}
// Create new provider config
export function createProviderConfig(provider: ProviderName): ProviderConfig {
return {
id: generateId(),
provider,
apiKey: "",
baseUrl: PROVIDER_INFO[provider].defaultBaseUrl,
models: [],
validated: false,
}
}
// Create new model config
export function createModelConfig(modelId: string): ModelConfig {
return {
id: generateId(),
modelId,
}
}
// Get all models as flattened list for dropdown (user-defined only)
export function flattenModels(config: MultiModelConfig): FlattenedModel[] {
const models: FlattenedModel[] = []
for (const provider of config.providers) {
// Use custom name if provided, otherwise use default provider label
const providerLabel =
provider.name || PROVIDER_INFO[provider.provider].label
for (const model of provider.models) {
models.push({
id: model.id,
modelId: model.modelId,
provider: provider.provider,
providerLabel,
apiKey: provider.apiKey,
baseUrl: provider.baseUrl,
// AWS Bedrock fields
awsAccessKeyId: provider.awsAccessKeyId,
awsSecretAccessKey: provider.awsSecretAccessKey,
awsRegion: provider.awsRegion,
awsSessionToken: provider.awsSessionToken,
// Vertex AI fields
vertexApiKey: provider.vertexApiKey,
validated: model.validated,
source: "user",
isDefault: false,
})
}
}
return models
}
// Find model by ID
export function findModelById(
config: MultiModelConfig,
modelId: string,
): FlattenedModel | undefined {
return flattenModels(config).find((m) => m.id === modelId)
}