feat(models): secure catalog and pin model versions
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
import {
|
||||
expectedModelProviderSecretRef,
|
||||
modelConnectionTestSucceeded,
|
||||
modelProviderModelsUrl,
|
||||
modelProviderSecretValue,
|
||||
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 } });
|
||||
}
|
||||
Reference in New Issue
Block a user