feat(models): secure catalog and pin model versions
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requirePermission } from "@/lib/admin/auth";
|
||||
import { pageOffset, queryAdminRows } from "@/lib/admin/database";
|
||||
import { adminErrorResponse, invalidQueryResponse, parseListQuery } from "@/lib/admin/http";
|
||||
export const runtime="nodejs";
|
||||
type Row={id:string;model_id:string;from_version:number|null;to_version:number;action:string;actor_email:string|null;reason:string;request_id:string;created_at:Date;total_count:string};
|
||||
export async function GET(request:Request){try{await requirePermission("models.read");const p=parseListQuery(request);if(!p.success)return invalidQueryResponse(p.error.flatten());const q=p.data.q?`%${p.data.q}%`:null;const rows=await queryAdminRows<Row>(`select e.id,c.model_id,f.version from_version,t.version to_version,e.action,u.email actor_email,e.reason,e.request_id,e.created_at,count(*) over()::text total_count from public.model_publish_events e join public.model_configs c on c.id=e.config_id left join public.model_config_versions f on f.id=e.from_version_id join public.model_config_versions t on t.id=e.to_version_id left join identity.users u on u.id=e.actor_user_id where ($1::text is null or c.model_id ilike $1 or u.email ilike $1 or e.request_id ilike $1) and ($2::text is null or e.action=$2) order by e.created_at desc limit $3 offset $4`,[q,p.data.status??null,p.data.pageSize,pageOffset(p.data.page,p.data.pageSize)]);return NextResponse.json({data:rows.map(r=>({id:r.id,modelId:r.model_id,fromVersion:r.from_version,toVersion:r.to_version,action:r.action,actorEmail:r.actor_email,reason:r.reason,requestId:r.request_id,createdAt:r.created_at.toISOString()})),total:Number(rows[0]?.total_count??0)});}catch(e){return adminErrorResponse(e)}}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { requirePermission } from "@/lib/admin/auth";
|
||||
import { pageOffset, queryAdminRows } from "@/lib/admin/database";
|
||||
import {
|
||||
adminErrorResponse,
|
||||
invalidQueryResponse,
|
||||
parseListQuery,
|
||||
requestId,
|
||||
requireAdminMutation,
|
||||
requireHighRiskAdminMutation,
|
||||
} from "@/lib/admin/http";
|
||||
import { assertAllowedModelProviderUrl, probeAllowedModelProvider } from "@/lib/epay/gateway-policy";
|
||||
import { handleAdminModelMutation, type AdminModelMutation } from "@/lib/admin/model-mutation-handler";
|
||||
import {
|
||||
invalidateLanguageModelCatalog,
|
||||
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"]),
|
||||
baseUrl: z.string().url().startsWith("https://").nullable().optional(),
|
||||
secretRef: secretRefSchema.optional(),
|
||||
enabled: z.boolean(),
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
}).strict();
|
||||
const settingsSchema = z.record(z.string(), z.unknown()).superRefine((value, context) => {
|
||||
if (modelSettingsContainSecrets(value)) {
|
||||
context.addIssue({ code: "custom", message: "settings 不允许包含密钥、令牌或连接凭据" });
|
||||
}
|
||||
});
|
||||
const draftSchema = z.object({
|
||||
action: z.literal("saveDraft"),
|
||||
modelId: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,63}$/),
|
||||
versionId: z.string().uuid().nullable().optional(),
|
||||
providerId: z.string().uuid(),
|
||||
label: z.string().trim().min(1).max(60),
|
||||
description: z.string().trim().max(200).default(""),
|
||||
providerModel: z.string().trim().min(1).max(160),
|
||||
modelTier: z.enum(["standard", "premium", "internal"]),
|
||||
creditCost: z.number().int().positive(),
|
||||
contextWindow: z.number().int().positive().nullable().optional(),
|
||||
inputCostMicrousdPerMillion: z.number().int().nonnegative(),
|
||||
outputCostMicrousdPerMillion: z.number().int().nonnegative(),
|
||||
enabled: z.boolean(),
|
||||
isDefault: z.boolean(),
|
||||
fallbackModelId: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,63}$/).nullable().optional(),
|
||||
settings: settingsSchema.default({}),
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
}).strict();
|
||||
const actionSchema = z.discriminatedUnion("action", [
|
||||
providerSchema,
|
||||
draftSchema,
|
||||
z.object({ action: z.literal("test"), versionId: z.string().uuid() }).strict(),
|
||||
z.object({ action: z.literal("publish"), versionId: z.string().uuid(), reason: z.string().trim().min(1).max(500) }).strict(),
|
||||
z.object({ action: z.literal("rollback"), configId: z.string().uuid(), targetVersion: z.number().int().positive(), reason: z.string().trim().min(1).max(500) }).strict(),
|
||||
]).superRefine((value, context) => {
|
||||
if (value.action === "saveDraft" && value.isDefault && !value.enabled) {
|
||||
context.addIssue({ code: "custom", path: ["isDefault"], message: "默认模型必须启用" });
|
||||
}
|
||||
});
|
||||
|
||||
type ProviderRow = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
provider_type: "openai" | "openai-compatible";
|
||||
base_url: string | null;
|
||||
secret_ref: string;
|
||||
enabled: boolean;
|
||||
updated_at: Date;
|
||||
};
|
||||
type ModelRow = {
|
||||
id: string;
|
||||
config_id: string;
|
||||
model_id: string;
|
||||
version: number;
|
||||
provider_id: string;
|
||||
provider_code: string;
|
||||
label: string;
|
||||
description: string;
|
||||
provider_model: string;
|
||||
model_tier: string;
|
||||
credit_cost: number;
|
||||
context_window: number | null;
|
||||
input_cost: string;
|
||||
output_cost: string;
|
||||
enabled: boolean;
|
||||
is_default: boolean;
|
||||
fallback_model_id: string | null;
|
||||
status: string;
|
||||
settings: Record<string, unknown>;
|
||||
created_at: Date;
|
||||
published_at: Date | null;
|
||||
total_count: string;
|
||||
};
|
||||
const modelOutput = (row: ModelRow) => ({
|
||||
id: row.id,
|
||||
configId: row.config_id,
|
||||
modelId: row.model_id,
|
||||
version: row.version,
|
||||
providerId: row.provider_id,
|
||||
providerCode: row.provider_code,
|
||||
label: row.label,
|
||||
description: row.description,
|
||||
providerModel: row.provider_model,
|
||||
modelTier: row.model_tier,
|
||||
creditCost: row.credit_cost,
|
||||
contextWindow: row.context_window,
|
||||
inputCostMicrousdPerMillion: Number(row.input_cost),
|
||||
outputCostMicrousdPerMillion: Number(row.output_cost),
|
||||
enabled: row.enabled,
|
||||
isDefault: row.is_default,
|
||||
fallbackModelId: row.fallback_model_id,
|
||||
status: row.status,
|
||||
settings: sanitizeModelSettings(row.settings),
|
||||
createdAt: row.created_at.toISOString(),
|
||||
publishedAt: row.published_at?.toISOString() ?? null,
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await requirePermission("models.read");
|
||||
const parsed = parseListQuery(request);
|
||||
if (!parsed.success) return invalidQueryResponse(parsed.error.flatten());
|
||||
const q = parsed.data.q ? `%${parsed.data.q}%` : null;
|
||||
const [models, providers] = await Promise.all([
|
||||
queryAdminRows<ModelRow>(`
|
||||
select v.id,v.config_id,c.model_id,v.version,v.provider_id,p.code provider_code,v.label,v.description,
|
||||
v.provider_model,v.model_tier,v.credit_cost,v.context_window,
|
||||
v.input_cost_microusd_per_million::text input_cost,
|
||||
v.output_cost_microusd_per_million::text output_cost,v.enabled,v.is_default,
|
||||
v.fallback_model_id,v.status,v.settings,v.created_at,v.published_at,count(*) over()::text total_count
|
||||
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 ($1::text is null or c.model_id ilike $1 or v.label ilike $1 or p.code ilike $1)
|
||||
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"),
|
||||
]);
|
||||
return NextResponse.json({
|
||||
data: models.map(modelOutput),
|
||||
total: Number(models[0]?.total_count ?? 0),
|
||||
providers: providers.map((provider) => ({
|
||||
id: provider.id,
|
||||
code: provider.code,
|
||||
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,
|
||||
})),
|
||||
enabled: provider.enabled,
|
||||
updatedAt: provider.updated_at.toISOString(),
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
return adminErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = actionSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!body.success) return invalidQueryResponse(body.error.flatten());
|
||||
const permission = body.data.action === "publish"
|
||||
? "models.publish"
|
||||
: body.data.action === "rollback"
|
||||
? "models.rollback"
|
||||
: body.data.action === "test"
|
||||
? "models.test"
|
||||
: "models.write";
|
||||
const highRisk = body.data.action === "saveProvider" || body.data.action === "publish" || body.data.action === "rollback";
|
||||
const session = highRisk
|
||||
? await requireHighRiskAdminMutation(request, permission)
|
||||
: await requireAdminMutation(request, permission);
|
||||
const rid = requestId(request);
|
||||
return await handleAdminModelMutation(
|
||||
body.data as AdminModelMutation,
|
||||
{ actorUserId: session.user.id, requestId: rid },
|
||||
{
|
||||
queryRows: (sql, values) => queryAdminRows<Record<string, unknown>>(sql, values),
|
||||
assertAllowedUrl: (value) => assertAllowedModelProviderUrl(value).then(() => undefined),
|
||||
probeAllowed: (value, apiKey) => probeAllowedModelProvider(value, apiKey),
|
||||
invalidateCatalog: invalidateLanguageModelCatalog,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
return adminErrorResponse(error);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user