feat(models): secure catalog and pin model versions

This commit is contained in:
Jesse_Chen
2026-08-06 20:15:08 +08:00
parent 7040fd998e
commit 28e04857fa
13 changed files with 1426 additions and 150 deletions
@@ -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 } });
}
@@ -4,7 +4,7 @@ export async function reserveConsultationModel<
>(
modelId: string,
resolveModel: (modelId: string) => Model | null,
reserveCredit: () => Promise<Reservation>,
reserveCredit: (model: Model) => Promise<Reservation>,
) {
const model = resolveModel(modelId);
if (!model) return { status: "unavailable" } as const;
@@ -13,6 +13,6 @@ export async function reserveConsultationModel<
status: "reserved",
model,
usageModelId: model.id,
reservation: await reserveCredit(),
reservation: await reserveCredit(model),
} as const;
}
+267
View File
@@ -0,0 +1,267 @@
import "server-only";
import type { MastraModelConfig } from "@mastra/core/llm";
import { queryAdminRows } from "@/lib/admin/database";
import { loadRuntimeFeatureFlags } from "@/lib/feature-flags";
import { assertAllowedModelProviderUrl } from "@/lib/epay/gateway-policy";
import { modelProviderSecretValue, type ModelProviderType } from "@/lib/model-provider-policy";
import {
languageModelCatalog as environmentCatalog,
type LanguageModelCatalog,
type ResolvedLanguageModel,
} from "@/mastra/model";
const cacheTtlMs = 15_000;
const secretSettingNames = new Set([
"apikey",
"authorization",
"cookie",
"databaseurl",
"connectionstring",
"password",
"privatekey",
"clientsecret",
"secret",
"token",
]);
type PublishedModelRow = {
model_id: string;
version: number;
label: string;
description: string;
provider_model: string;
credit_cost: number;
is_default: boolean;
provider_code: string;
provider_type: ModelProviderType;
base_url: string | null;
secret_ref: string;
input_cost: string | number;
output_cost: string | number;
};
type Cache = { expiresAt: number; catalog: LanguageModelCatalog };
type Circuit = { failures: number; openUntil: number };
const state = globalThis as typeof globalThis & {
jyotishaModelCatalogCache?: Cache;
jyotishaModelCatalogCircuit?: Circuit;
};
function isSecretSettingKey(key: string) {
const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, "");
return secretSettingNames.has(normalized)
|| normalized.endsWith("apikey")
|| normalized.endsWith("password")
|| normalized.endsWith("authorization")
|| normalized.endsWith("cookie")
|| normalized.endsWith("databaseurl")
|| normalized.endsWith("connectionstring")
|| normalized.endsWith("privatekey")
|| normalized.endsWith("clientsecret")
|| normalized.endsWith("accesstoken")
|| normalized.endsWith("refreshtoken")
|| normalized.endsWith("authtoken")
|| normalized.endsWith("bearertoken");
}
export function modelSettingsContainSecrets(value: unknown): boolean {
if (Array.isArray(value)) return value.some(modelSettingsContainSecrets);
if (!value || typeof value !== "object") return false;
return Object.entries(value).some(([key, nested]) => isSecretSettingKey(key) || modelSettingsContainSecrets(nested));
}
export function sanitizeModelSettings(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sanitizeModelSettings);
if (!value || typeof value !== "object") return value;
return Object.fromEntries(Object.entries(value)
.filter(([key]) => !isSecretSettingKey(key))
.map(([key, nested]) => [key, sanitizeModelSettings(nested)]));
}
function publicModel(model: ResolvedLanguageModel) {
return {
id: model.id,
label: model.label,
description: model.description,
creditCost: model.creditCost,
isDefault: model.isDefault,
};
}
async function resolveRow(row: PublishedModelRow): Promise<ResolvedLanguageModel | null> {
const apiKey = modelProviderSecretValue({
code: row.provider_code,
providerType: row.provider_type,
secretRef: row.secret_ref,
});
if (!apiKey) return null;
const model: MastraModelConfig = row.provider_type === "openai"
? {
providerId: "openai",
modelId: row.provider_model.replace(/^openai\//, ""),
apiKey,
}
: {
providerId: row.provider_code,
modelId: row.provider_model,
url: (await assertAllowedModelProviderUrl(row.base_url ?? "")).url.toString(),
apiKey,
};
return {
id: row.model_id,
label: row.label,
description: row.description,
creditCost: row.credit_cost,
isDefault: row.is_default,
mode: row.provider_type === "openai" ? "openai" : "compatible",
model,
configVersion: row.version,
inputCostMicrousdPerMillion: Number(row.input_cost),
outputCostMicrousdPerMillion: Number(row.output_cost),
};
}
async function readPublishedCatalog(): Promise<LanguageModelCatalog | null> {
const rows = await queryAdminRows<PublishedModelRow>(`
select c.model_id,v.version,v.label,v.description,v.provider_model,v.credit_cost,v.is_default,
p.code provider_code,p.provider_type,p.base_url,p.secret_ref,
v.input_cost_microusd_per_million input_cost,v.output_cost_microusd_per_million output_cost
from public.model_config_versions v
join public.model_configs c on c.id=v.config_id
join public.model_providers p on p.id=v.provider_id
where v.status='published' and v.enabled and p.enabled
order by v.is_default desc,c.model_id
`);
if (rows.length === 0) return null;
const models: ResolvedLanguageModel[] = [];
for (const row of rows) {
try {
const model = await resolveRow(row);
if (model) models.push(model);
} catch {
// Unsafe or unresolvable providers are excluded at the real runtime boundary.
}
}
const defaults = models.filter((model) => model.isDefault);
if (defaults.length !== 1) return null;
return {
models,
publicModels: models.map(publicModel),
defaultModelId: defaults[0]!.id,
issues: [],
};
}
function positiveInteger(value: unknown, fallback: number, maximum: number) {
return typeof value === "number" && Number.isInteger(value) && value > 0
? Math.min(value, maximum)
: fallback;
}
async function runtimeSafeEnvironmentCatalog(): Promise<LanguageModelCatalog> {
const models: ResolvedLanguageModel[] = [];
const issues = [...environmentCatalog.issues];
for (const model of environmentCatalog.models) {
if (model.mode !== "compatible") {
models.push(model);
continue;
}
const config = model.model;
const url = typeof config === "object" && config !== null && "url" in config && typeof config.url === "string"
? config.url
: "";
try {
await assertAllowedModelProviderUrl(url);
models.push(model);
} catch {
issues.push(`runtime_provider_unsafe:${model.id}`);
}
}
const defaultModelId = models.some((model) => model.id === environmentCatalog.defaultModelId)
? environmentCatalog.defaultModelId
: null;
if (!defaultModelId && !issues.includes("default_model_unavailable")) issues.push("default_model_unavailable");
return { models, publicModels: models.map(publicModel), defaultModelId, issues };
}
function recordCatalogFailure(threshold: number, cooldownMs: number) {
const circuit = state.jyotishaModelCatalogCircuit ?? { failures: 0, openUntil: 0 };
const failures = circuit.failures + 1;
state.jyotishaModelCatalogCircuit = {
failures,
openUntil: failures >= threshold ? Date.now() + cooldownMs : 0,
};
}
export async function loadLanguageModelCatalog(): Promise<LanguageModelCatalog> {
const cached = state.jyotishaModelCatalogCache;
if (cached && cached.expiresAt > Date.now()) return cached.catalog;
let catalog = await runtimeSafeEnvironmentCatalog();
if (process.env.AUTH_PROVIDER?.trim() !== "self-hosted") {
state.jyotishaModelCatalogCache = { expiresAt: Date.now() + cacheTtlMs, catalog };
return catalog;
}
let breakerEnabled = false;
let threshold = 5;
let cooldownMs = 300_000;
try {
const flags = await loadRuntimeFeatureFlags(["models.database_catalog", "models.circuit_breaker"]);
if (flags.get("models.database_catalog")?.enabled) {
const breaker = flags.get("models.circuit_breaker");
breakerEnabled = Boolean(breaker?.enabled);
threshold = positiveInteger(breaker?.config.failureThreshold, threshold, 100);
cooldownMs = positiveInteger(breaker?.config.cooldownSeconds, 300, 3_600) * 1_000;
const circuit = state.jyotishaModelCatalogCircuit ?? { failures: 0, openUntil: 0 };
if (!breakerEnabled) state.jyotishaModelCatalogCircuit = { failures: 0, openUntil: 0 };
if (!breakerEnabled || circuit.openUntil <= Date.now()) {
const published = await readPublishedCatalog();
if (published) {
catalog = published;
state.jyotishaModelCatalogCircuit = { failures: 0, openUntil: 0 };
} else if (breakerEnabled) {
recordCatalogFailure(threshold, cooldownMs);
}
}
}
} catch {
if (breakerEnabled) recordCatalogFailure(threshold, cooldownMs);
}
state.jyotishaModelCatalogCache = { expiresAt: Date.now() + cacheTtlMs, catalog };
return catalog;
}
async function readVersion(modelId: string, version: number) {
const rows = await queryAdminRows<PublishedModelRow>(`
select c.model_id,v.version,v.label,v.description,v.provider_model,v.credit_cost,v.is_default,
p.code provider_code,p.provider_type,p.base_url,p.secret_ref,
v.input_cost_microusd_per_million input_cost,v.output_cost_microusd_per_million output_cost
from public.model_config_versions v
join public.model_configs c on c.id=v.config_id
join public.model_providers p on p.id=v.provider_id
where c.model_id=$1 and v.version=$2 and v.status in ('published','retired') and v.enabled and p.enabled
`, [modelId, version]);
return rows[0] ? resolveRow(rows[0]) : null;
}
export async function resolveSessionLanguageModel(modelId: string, configVersion: number | null | undefined) {
if (configVersion !== null && configVersion !== undefined) {
if (process.env.AUTH_PROVIDER?.trim() !== "self-hosted") return null;
try {
return await readVersion(modelId, configVersion);
} catch {
return null;
}
}
const catalog = await loadLanguageModelCatalog();
return catalog.models.find((model) => model.id === modelId) ?? null;
}
export function invalidateLanguageModelCatalog() {
delete state.jyotishaModelCatalogCache;
}
+55
View File
@@ -0,0 +1,55 @@
export type ModelProviderType = "openai" | "openai-compatible";
export type ModelProviderSecret = Readonly<{
code: string;
providerType: ModelProviderType;
secretRef: string;
}>;
const fixedModelSecretEnvironmentNames = new Set([
"OPENAI_API_KEY",
"DEEPSEEK_API_KEY",
"LLM_API_KEY",
]);
export function isAllowedModelSecretEnvironmentName(name: string) {
return fixedModelSecretEnvironmentNames.has(name)
|| /^MODEL_PROVIDER_[A-Z][A-Z0-9_]{0,63}_API_KEY$/.test(name);
}
export function expectedModelProviderSecretRef(code: string, providerType: ModelProviderType) {
if (providerType === "openai") return "env:OPENAI_API_KEY";
if (code === "deepseek") return "env:DEEPSEEK_API_KEY";
if (code === "legacy-compatible") return "env:LLM_API_KEY";
return `env:MODEL_PROVIDER_${code.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_API_KEY`;
}
export function modelProviderSecretValue(
provider: ModelProviderSecret,
environment: Readonly<Record<string, string | undefined>> = process.env,
) {
const expected = expectedModelProviderSecretRef(provider.code, provider.providerType);
if (provider.secretRef !== expected) return "";
const environmentName = expected.slice(4);
if (!isAllowedModelSecretEnvironmentName(environmentName)) return "";
return environment[environmentName]?.trim() ?? "";
}
export function modelProviderModelsUrl(provider: Readonly<{
providerType: ModelProviderType;
baseUrl: string | null;
}>) {
if (provider.providerType === "openai") return "https://api.openai.com/v1/models";
const url = new URL(provider.baseUrl ?? "");
url.username = "";
url.password = "";
url.search = "";
url.hash = "";
const basePath = url.pathname.replace(/\/+$/, "");
url.pathname = basePath.endsWith("/models") ? basePath : `${basePath}/models`;
return url.toString();
}
export function modelConnectionTestSucceeded(status: number) {
return status >= 200 && status <= 299;
}
+2 -2
View File
@@ -4,7 +4,7 @@ const publicLanguageModelSchema = z.object({
id: z.string().trim().min(1).max(64).regex(/^[a-z0-9][a-z0-9._-]*$/),
label: z.string().trim().min(1).max(60),
description: z.string().trim().max(100),
creditCost: z.literal(1),
creditCost: z.number().int().positive(),
isDefault: z.boolean(),
}).strict();
@@ -26,7 +26,7 @@ export type PublicLanguageModel = {
readonly id: string;
readonly label: string;
readonly description: string;
readonly creditCost: 1;
readonly creditCost: number;
readonly isDefault: boolean;
};
@@ -1,4 +1,5 @@
type SessionModelWrite = {
// model_config_version is server-owned and pinned by the database trigger.
readonly values: { readonly model_id: string };
readonly sessionId: string;
readonly userId: string;