feat(admin): configure model providers from database

This commit is contained in:
Jesse_Chen
2026-08-07 20:03:20 +08:00
parent d63f10dcc2
commit 3089316d2e
31 changed files with 871 additions and 1430 deletions
+1 -17
View File
@@ -63,7 +63,7 @@ export function adminProofSigningSecret(
): string {
const secret = env.BETTER_AUTH_USER_SECRET?.trim();
if (!secret || secret.length < 32) {
throw new AdminAuthorizationError("MFA 服务暂时不可用", 503);
throw new AdminAuthorizationError("安全验证服务暂时不可用", 503);
}
return secret;
}
@@ -86,27 +86,11 @@ export function readAdminMfaStatus(
return resolveAdminMfaStatus(session.requiresMfa, enrolled, verified);
}
export function requireAdminMfaIfRequired(
request: Request,
session: AdminSession,
): AdminMfaStatus {
const status = readAdminMfaStatus(request, session);
if (!status.required) return status;
if (!status.enrolled) {
throw new AdminAuthorizationError("此管理员角色必须先启用 MFA", 403);
}
if (!status.verified) {
throw new AdminAuthorizationError("请先完成当前会话的 MFA 验证", 403);
}
return status;
}
export async function requireHighRiskAdminMutation(
request: Request,
permission: AdminPermission,
): Promise<AdminSession> {
const session = await requireAdminMutation(request, permission);
requireAdminMfaIfRequired(request, session);
const origin = new URL(request.url).origin;
const valid = verifyHighRiskAdminProof(
+16 -236
View File
@@ -1,238 +1,18 @@
import {
expectedModelProviderSecretRef,
modelConnectionTestSucceeded,
modelProviderModelsUrl,
modelProviderSecretValue,
type ModelProviderType,
} from "../model-provider-policy.ts";
import { decryptModelProviderApiKey, encryptModelProviderApiKey, modelConnectionTestSucceeded, modelProviderModelsUrl, modelProviderRequestHeaders, type ModelProviderType } from "../model-provider-policy.ts";
type SaveProviderAction = {
action: "saveProvider";
id?: string | null;
code: string;
name: string;
providerType: ModelProviderType;
baseUrl?: string | null;
secretRef?: string;
enabled: boolean;
reason: string;
};
type SaveDraftAction = {
action: "saveDraft";
modelId: string;
versionId?: string | null;
providerId: string;
label: string;
description: string;
providerModel: string;
modelTier: "standard" | "premium" | "internal";
creditCost: number;
contextWindow?: number | null;
inputCostMicrousdPerMillion: number;
outputCostMicrousdPerMillion: number;
enabled: boolean;
isDefault: boolean;
fallbackModelId?: string | null;
settings: Record<string, unknown>;
reason: string;
};
export type AdminModelMutation = SaveProviderAction | SaveDraftAction | {
action: "test";
versionId: string;
} | {
action: "publish";
versionId: string;
reason: string;
} | {
action: "rollback";
configId: string;
targetVersion: number;
reason: string;
};
type ProviderRow = {
id: string;
code: string;
provider_type: ModelProviderType;
base_url: string | null;
secret_ref: string;
enabled: boolean;
version_id: string;
version_enabled: boolean;
};
type Dependencies = {
queryRows(sql: string, values?: readonly unknown[]): Promise<readonly Record<string, unknown>[]>;
assertAllowedUrl(value: string): Promise<unknown>;
probeAllowed(value: string, apiKey: string): Promise<number>;
invalidateCatalog(): void;
environment?: Readonly<Record<string, string | undefined>>;
};
function json(body: unknown, status = 200) {
return Response.json(body, { status });
}
function isImmutableProviderError(error: unknown) {
return error instanceof Error && error.message.includes("model_provider_runtime_immutable");
}
async function versionProvider(
dependencies: Dependencies,
where: string,
values: readonly unknown[],
) {
const rows = await dependencies.queryRows(`
select p.id,p.code,p.provider_type,p.base_url,p.secret_ref,p.enabled,
v.id version_id,v.enabled version_enabled
from public.model_config_versions v
join public.model_providers p on p.id=v.provider_id
where ${where}
`, values);
return (rows[0] as ProviderRow | undefined) ?? null;
}
async function runnableProvider(
provider: ProviderRow,
dependencies: Dependencies,
) {
if (!provider.enabled || !provider.version_enabled) return null;
const apiKey = modelProviderSecretValue({
code: provider.code,
providerType: provider.provider_type,
secretRef: provider.secret_ref,
}, dependencies.environment ?? process.env);
if (!apiKey) return null;
if (provider.provider_type === "openai-compatible") {
await dependencies.assertAllowedUrl(provider.base_url ?? "");
}
return apiKey;
}
async function probeAndRecord(
provider: ProviderRow,
actorUserId: string,
requestId: string,
dependencies: Dependencies,
) {
const apiKey = await runnableProvider(provider, dependencies);
if (!apiKey) return { recorded: false, status: 0, success: false };
let status = 0;
try {
status = await dependencies.probeAllowed(modelProviderModelsUrl({
providerType: provider.provider_type,
baseUrl: provider.base_url,
}), apiKey);
} catch {
status = 0;
}
await dependencies.queryRows(
"select public.admin_record_model_connection_test($1,$2,$3,$4) id",
[actorUserId, provider.version_id, status, requestId],
);
return { recorded: true, status, success: modelConnectionTestSucceeded(status) };
}
export async function handleAdminModelMutation(
action: AdminModelMutation,
context: Readonly<{ actorUserId: string; requestId: string }>,
dependencies: Dependencies,
) {
if (action.action === "saveProvider") {
const secretRef = expectedModelProviderSecretRef(action.code, action.providerType);
if (action.secretRef && action.secretRef !== secretRef) {
return json({ error: "模型供应商密钥引用不受允许" }, 400);
}
if (action.providerType === "openai-compatible") {
await dependencies.assertAllowedUrl(action.baseUrl ?? "");
}
if (action.enabled && !modelProviderSecretValue({
code: action.code,
providerType: action.providerType,
secretRef,
}, dependencies.environment ?? process.env)) {
return json({ error: "模型供应商密钥未配置" }, 409);
}
let rows: readonly Record<string, unknown>[];
try {
rows = await dependencies.queryRows(
"select public.admin_save_model_provider($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) id",
[context.actorUserId, action.id ?? null, action.code, action.name, action.providerType,
action.providerType === "openai" ? null : action.baseUrl ?? null,
secretRef, action.enabled, action.reason, context.requestId],
);
} catch (error) {
if (isImmutableProviderError(error)) {
return json({
error: "已发布或已退役版本使用的供应商连接配置不可修改,请新建供应商和模型版本后重新测试并发布",
code: "model_provider_runtime_immutable",
}, 409);
}
throw error;
}
return json({ data: { id: rows[0]!.id, requestId: context.requestId } });
}
if (action.action === "saveDraft") {
const rows = await dependencies.queryRows(
"select public.admin_save_model_draft($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16::jsonb,$17,$18) id",
[context.actorUserId, action.modelId, action.versionId ?? null, action.providerId, action.label,
action.description, action.providerModel, action.modelTier, action.creditCost, action.contextWindow ?? null,
action.inputCostMicrousdPerMillion, action.outputCostMicrousdPerMillion, action.enabled, action.isDefault,
action.fallbackModelId ?? null, JSON.stringify(action.settings), action.reason, context.requestId],
);
return json({ data: { id: rows[0]!.id, requestId: context.requestId } });
}
if (action.action === "test") {
const provider = await versionProvider(
dependencies,
"v.id=$1 and v.status in ('draft','published','retired')",
[action.versionId],
);
if (!provider) return json({ error: "模型版本不存在" }, 404);
const result = await probeAndRecord(provider, context.actorUserId, context.requestId, dependencies);
if (!result.recorded) return json({ error: "模型版本或供应商未启用,或部署密钥不可用" }, 409);
return json({
data: {
id: provider.version_id,
reachable: result.success,
status: result.status,
secretConfigured: true,
requestId: context.requestId,
},
}, result.success ? 200 : 409);
}
if (action.action === "publish") {
const rows = await dependencies.queryRows(
"select public.admin_publish_model($1,$2,$3,$4) id",
[context.actorUserId, action.versionId, action.reason, context.requestId],
);
dependencies.invalidateCatalog();
return json({ data: { id: rows[0]!.id, requestId: context.requestId } });
}
const provider = await versionProvider(
dependencies,
"v.config_id=$1 and v.version=$2 and v.status='retired'",
[action.configId, action.targetVersion],
);
if (!provider) return json({ error: "回滚版本不存在" }, 404);
const result = await probeAndRecord(provider, context.actorUserId, context.requestId, dependencies);
if (!result.recorded) return json({ error: "回滚版本或供应商未启用,或部署密钥不可用" }, 409);
if (!result.success) {
return json({
error: "回滚版本运行时连接测试失败",
data: { status: result.status, requestId: context.requestId },
}, 409);
}
const rows = await dependencies.queryRows(
"select public.admin_rollback_model($1,$2,$3,$4,$5) id",
[context.actorUserId, action.configId, action.targetVersion, action.reason, context.requestId],
);
dependencies.invalidateCatalog();
return json({ data: { id: rows[0]!.id, requestId: context.requestId } });
type SaveProviderAction={action:"saveProvider";id?:string|null;name:string;providerType:ModelProviderType;baseUrl?:string|null;apiKey?:string;enabled:boolean;reason:string};
type SaveDraftAction={action:"saveDraft";modelId:string;versionId?:string|null;providerId:string;label:string;description:string;providerModel:string;modelTier:"standard"|"premium"|"internal";creditCost:number;contextWindow?:number|null;inputCostMicrousdPerMillion:number;outputCostMicrousdPerMillion:number;enabled:boolean;isDefault:boolean;fallbackModelId?:string|null;settings:Record<string,unknown>;reason:string};
export type AdminModelMutation=SaveProviderAction|SaveDraftAction|{action:"test";versionId:string}|{action:"publish";versionId:string;reason:string}|{action:"rollback";configId:string;targetVersion:number;reason:string};
type ProviderRow={id:string;code:string;provider_type:ModelProviderType;base_url:string|null;encrypted_api_key:string|null;enabled:boolean;version_id:string;version_enabled:boolean};
type Dependencies={queryRows(sql:string,values?:readonly unknown[]):Promise<readonly Record<string,unknown>[]>;assertAllowedUrl(value:string):Promise<unknown>;probeAllowed(value:string,headers:Readonly<Record<string,string>>):Promise<number>;invalidateCatalog():void;environment?:Readonly<Record<string,string|undefined>>};
const json=(body:unknown,status=200)=>Response.json(body,{status});
const immutable=(e:unknown)=>e instanceof Error&&e.message.includes("model_provider_runtime_immutable");
async function versionProvider(d:Dependencies,where:string,values:readonly unknown[]){const rows=await d.queryRows(`select p.id,p.code,p.provider_type,p.base_url,p.encrypted_api_key,p.enabled,v.id version_id,v.enabled version_enabled from public.model_config_versions v join public.model_providers p on p.id=v.provider_id where ${where}`,values);return rows[0] as ProviderRow|undefined;}
async function probeAndRecord(p:ProviderRow,actor:string,rid:string,d:Dependencies){if(!p.enabled||!p.version_enabled||!p.encrypted_api_key)return{recorded:false,status:0,success:false};let key="";try{key=decryptModelProviderApiKey({encryptedApiKey:p.encrypted_api_key},d.environment);}catch{return{recorded:false,status:0,success:false};}if(p.provider_type==="openai-compatible")await d.assertAllowedUrl(p.base_url??"");let status=0;try{status=await d.probeAllowed(modelProviderModelsUrl({providerType:p.provider_type,baseUrl:p.base_url}),modelProviderRequestHeaders(p.provider_type,key));}catch{}await d.queryRows("select public.admin_record_model_connection_test($1,$2,$3,$4) id",[actor,p.version_id,status,rid]);return{recorded:true,status,success:modelConnectionTestSucceeded(status)};}
export async function handleAdminModelMutation(a:AdminModelMutation,c:Readonly<{actorUserId:string;requestId:string}>,d:Dependencies){
if(a.action==="saveProvider"){if(a.providerType==="openai-compatible")await d.assertAllowedUrl(a.baseUrl??"");const key=a.apiKey?.trim();let encrypted:string|null=null;if(key)encrypted=encryptModelProviderApiKey(key,d.environment);else if(!a.id&&a.enabled)return json({error:"模型供应商密钥未配置"},409);try{const rows=await d.queryRows("select public.admin_save_model_provider($1,$2,$3,$4,$5,$6,$7,$8,$9) id",[c.actorUserId,a.id??null,a.name,a.providerType,a.providerType==="openai-compatible"?a.baseUrl??null:null,encrypted,a.enabled,a.reason,c.requestId]);return json({data:{id:rows[0]!.id,requestId:c.requestId}});}catch(e){if(immutable(e))return json({error:"已发布或已退役版本使用的供应商连接配置不可修改,请新建供应商和模型版本后重新测试并发布",code:"model_provider_runtime_immutable"},409);if(e instanceof Error&&e.message.includes("model_provider_code_immutable"))return json({error:"供应商代码不可修改",code:"model_provider_code_immutable"},409);throw e;}}
if(a.action==="saveDraft"){const rows=await d.queryRows("select public.admin_save_model_draft($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16::jsonb,$17,$18) id",[c.actorUserId,a.modelId,a.versionId??null,a.providerId,a.label,a.description,a.providerModel,a.modelTier,a.creditCost,a.contextWindow??null,a.inputCostMicrousdPerMillion,a.outputCostMicrousdPerMillion,a.enabled,a.isDefault,a.fallbackModelId??null,JSON.stringify(a.settings),a.reason,c.requestId]);return json({data:{id:rows[0]!.id,requestId:c.requestId}});}
if(a.action==="test"){const p=await versionProvider(d,"v.id=$1 and v.status in ('draft','published','retired')",[a.versionId]);if(!p)return json({error:"模型版本不存在"},404);const r=await probeAndRecord(p,c.actorUserId,c.requestId,d);if(!r.recorded)return json({error:"模型版本或供应商未启用,或数据库密钥不可用"},409);return json({data:{id:p.version_id,reachable:r.success,status:r.status,secretConfigured:true,requestId:c.requestId}},r.success?200:409);}
if(a.action==="publish"){const rows=await d.queryRows("select public.admin_publish_model($1,$2,$3,$4) id",[c.actorUserId,a.versionId,a.reason,c.requestId]);d.invalidateCatalog();return json({data:{id:rows[0]!.id,requestId:c.requestId}});}
const p=await versionProvider(d,"v.config_id=$1 and v.version=$2 and v.status='retired'",[a.configId,a.targetVersion]);if(!p)return json({error:"回滚版本不存在"},404);const r=await probeAndRecord(p,c.actorUserId,c.requestId,d);if(!r.recorded)return json({error:"回滚版本或供应商未启用,或数据库密钥不可用"},409);if(!r.success)return json({error:"回滚版本运行时连接测试失败",data:{status:r.status,requestId:c.requestId}},409);const rows=await d.queryRows("select public.admin_rollback_model($1,$2,$3,$4,$5) id",[c.actorUserId,a.configId,a.targetVersion,a.reason,c.requestId]);d.invalidateCatalog();return json({data:{id:rows[0]!.id,requestId:c.requestId}});
}