feat(admin): configure model providers from database
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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}});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user