feat(admin): configure model providers from database

This commit is contained in:
Jesse_Chen
2026-08-07 20:03:13 +08:00
parent d63f10dcc2
commit 3089316d2e
31 changed files with 871 additions and 1430 deletions
@@ -0,0 +1,11 @@
import { z } from "zod";
import { queryAdminRows } from "@/lib/admin/database";
import { adminErrorResponse, invalidQueryResponse, requireHighRiskAdminMutation } from "@/lib/admin/http";
import { requestAllowedModelProvider } from "@/lib/epay/gateway-policy";
import { decryptModelProviderApiKey, modelProviderModelsUrl, modelProviderRequestHeaders, type ModelProviderType } from "@/lib/model-provider-policy";
export const runtime="nodejs";
const schema=z.object({providerId:z.string().uuid()}).strict();
type Row={provider_type:ModelProviderType;base_url:string|null;encrypted_api_key:string|null;enabled:boolean};
function normalize(payload:unknown){const data=payload&&typeof payload==="object"&&"data" in payload?(payload as {data?:unknown}).data:null;if(!Array.isArray(data))return[];const seen=new Set<string>();const out:{id:string;label:string}[]=[];for(const item of data){if(out.length>=200)break;if(!item||typeof item!=="object")continue;const raw="id" in item?(item as {id?:unknown}).id:undefined;if(typeof raw!=="string")continue;const id=raw.trim();if(!id||id.length>160||seen.has(id))continue;seen.add(id);const display="display_name" in item?(item as {display_name?:unknown}).display_name:undefined;out.push({id,label:typeof display==="string"&&display.trim()?display.trim().slice(0,160):id});}return out;}
export async function POST(request:Request){try{await requireHighRiskAdminMutation(request,"models.test");const parsed=schema.safeParse(await request.json().catch(()=>null));if(!parsed.success)return invalidQueryResponse(parsed.error.flatten());const rows=await queryAdminRows<Row>("select provider_type,base_url,encrypted_api_key,enabled from public.model_providers where id=$1",[parsed.data.providerId]);const p=rows[0];if(!p||!p.enabled)return Response.json({error:"模型供应商不可用"},{status:404});let key="";try{key=decryptModelProviderApiKey({encryptedApiKey:p.encrypted_api_key});}catch{return Response.json({error:"模型供应商配置不可用"},{status:409});}const upstream=await requestAllowedModelProvider(modelProviderModelsUrl({providerType:p.provider_type,baseUrl:p.base_url}),modelProviderRequestHeaders(p.provider_type,key),process.env,{timeoutMs:8000,maxResponseBytes:256*1024});if(upstream.status<200||upstream.status>299)return Response.json({error:"模型发现失败"},{status:502});let payload:unknown;try{payload=JSON.parse(upstream.body.toString("utf8"));}catch{return Response.json({error:"模型发现失败"},{status:502});}return Response.json({data:normalize(payload)});}catch(e){return adminErrorResponse(e);}}
+7 -14
View File
@@ -18,19 +18,16 @@ import {
modelSettingsContainSecrets,
sanitizeModelSettings,
} from "@/lib/model-catalog";
import { modelProviderSecretValue } from "@/lib/model-provider-policy";
export const runtime = "nodejs";
const secretRefSchema = z.string().regex(/^env:[A-Z][A-Z0-9_]*$/);
const providerSchema = z.object({
action: z.literal("saveProvider"),
id: z.string().uuid().nullable().optional(),
code: z.string().regex(/^[a-z][a-z0-9_-]{1,63}$/),
name: z.string().trim().min(1).max(80),
providerType: z.enum(["openai", "openai-compatible"]),
providerType: z.enum(["openai", "openai-compatible", "anthropic"]),
baseUrl: z.string().url().startsWith("https://").nullable().optional(),
secretRef: secretRefSchema.optional(),
apiKey: z.string().max(4096).optional(),
enabled: z.boolean(),
reason: z.string().trim().min(1).max(500),
}).strict();
@@ -74,9 +71,9 @@ type ProviderRow = {
id: string;
code: string;
name: string;
provider_type: "openai" | "openai-compatible";
provider_type: "openai" | "openai-compatible" | "anthropic";
base_url: string | null;
secret_ref: string;
encrypted_api_key: string | null;
enabled: boolean;
updated_at: Date;
};
@@ -148,7 +145,7 @@ export async function GET(request: Request) {
and ($2::text is null or v.status=$2)
order by v.created_at desc limit $3 offset $4
`, [q, parsed.data.status ?? null, parsed.data.pageSize, pageOffset(parsed.data.page, parsed.data.pageSize)]),
queryAdminRows<ProviderRow>("select id,code,name,provider_type,base_url,secret_ref,enabled,updated_at from public.model_providers order by code"),
queryAdminRows<ProviderRow>("select id,code,name,provider_type,base_url,(encrypted_api_key is not null) secret_configured,enabled,updated_at from public.model_providers order by code"),
]);
return NextResponse.json({
data: models.map(modelOutput),
@@ -159,11 +156,7 @@ export async function GET(request: Request) {
name: provider.name,
providerType: provider.provider_type,
baseUrl: provider.base_url,
secretConfigured: Boolean(modelProviderSecretValue({
code: provider.code,
providerType: provider.provider_type,
secretRef: provider.secret_ref,
})),
secretConfigured: Boolean((provider as ProviderRow & { secret_configured?: boolean }).secret_configured),
enabled: provider.enabled,
updatedAt: provider.updated_at.toISOString(),
})),
@@ -195,7 +188,7 @@ export async function POST(request: Request) {
{
queryRows: (sql, values) => queryAdminRows<Record<string, unknown>>(sql, values),
assertAllowedUrl: (value) => assertAllowedModelProviderUrl(value).then(() => undefined),
probeAllowed: (value, apiKey) => probeAllowedModelProvider(value, apiKey),
probeAllowed: (value, headers) => probeAllowedModelProvider(value, headers),
invalidateCatalog: invalidateLanguageModelCatalog,
},
);
@@ -19,7 +19,6 @@ import {
adminErrorResponse,
invalidQueryResponse,
requestCookie,
requireAdminMfaIfRequired,
requireAdminMutation,
} from "@/lib/admin/http";
@@ -43,7 +42,6 @@ export async function POST(request: Request) {
if (!parsed.success) return invalidQueryResponse(parsed.error.flatten());
const session = await requireAdminMutation(request, parsed.data.permission);
requireAdminMfaIfRequired(request, session);
const auth = getIdentityEmailOtpApi();
const proofContext = {
userId: session.user.id,