feat(models): secure catalog and pin model versions

This commit is contained in:
Jesse_Chen
2026-08-06 19:49:26 +08:00
parent 7040fd998e
commit 28e04857fa
13 changed files with 1426 additions and 150 deletions
@@ -3,9 +3,9 @@ import { z } from "zod";
import { parseAgentReply } from "@/lib/agent-reply";
import type { ChatMessage } from "@/lib/chat-message-view";
import { getAgenticRectificationAgent } from "@/mastra/agentic-rectification";
import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model";
import { blocksPromptExtraction } from "@/lib/consult-safety";
import { runCreditRpc } from "@/lib/consultation-billing";
import { authorizeUsage, completeUsage, releaseUsage } from "@/lib/consultation-billing";
import { resolveSessionLanguageModel } from "@/lib/model-catalog";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import {
@@ -80,29 +80,26 @@ function currentTimeContext(now = new Date()) {
return `服务端当前时间(权威):${now.toISOString()};中国标准时间(UTC+8):${chinaTime}。涉及“现在、今天、今年、未来几个月”等相对时间时,以此为准。`;
}
async function recordModelUsage(
async function rectificationBillingRequestId(
accounting: ReturnType<typeof createAdminSupabaseClient>,
userId: string,
requestId: string,
modelId: string,
usage: Promise<{ inputTokens?: number; outputTokens?: number }>,
sessionId: string,
) {
try {
const resolved = await usage;
const { error } = await accounting
.from("credit_transactions")
.update({
model: modelId,
input_tokens: Math.max(0, Math.trunc(resolved.inputTokens ?? 0)),
output_tokens: Math.max(0, Math.trunc(resolved.outputTokens ?? 0)),
})
.eq("user_id", userId)
.eq("transaction_type", "reserve")
.eq("request_id", requestId);
if (error) console.warn(`[agentic-rectification] unable to record usage request=${requestId}`);
} catch (error) {
console.warn(`[agentic-rectification] usage read failed request=${requestId}`, error instanceof Error ? error.name : "UnknownError");
}
const billingRequestPrefix = `rectification:${sessionId}`;
const { data, error } = await accounting
.from("usage_reservations")
.select("request_id,status")
.eq("user_id", userId)
.eq("feature_key", "rectification")
.like("request_id", `${billingRequestPrefix}%`);
if (error) throw new Error("RectificationBillingLookupError");
const reservations = (data ?? []) as Array<{ request_id: string; status: string }>;
const active = reservations.find((item) => item.status === "completed")
?? reservations.find((item) => item.status === "reserved");
if (active) return active.request_id;
return reservations.length === 0
? billingRequestPrefix
: `${billingRequestPrefix}:retry:${reservations.length}`;
}
export async function GET(request: Request) {
@@ -183,7 +180,7 @@ export async function POST(request: Request) {
const { data: chatSession, error: chatSessionError } = await supabase
.from("chat_sessions")
.select("id,messages,session_type")
.select("id,messages,session_type,model_id,model_config_version")
.eq("id", parsed.data.sessionId)
.eq("user_id", userId)
.maybeSingle();
@@ -251,8 +248,16 @@ export async function POST(request: Request) {
);
}
const selectedModel = (conversation.modelId ? resolveLanguageModel(conversation.modelId) : null)
?? defaultLanguageModel();
if (!chatSession.model_id || (conversation.modelId && conversation.modelId !== chatSession.model_id)) {
return NextResponse.json(
{ error: "会话模型已经变化", message: "请刷新生时校正会话后重试,本次不会扣除点数。" },
{ status: 409 },
);
}
const selectedModel = await resolveSessionLanguageModel(
chatSession.model_id,
chatSession.model_config_version,
);
if (!selectedModel) {
return NextResponse.json(
{ error: "模型暂不可用", message: "请选择其他模型后重新发送,本次不会扣除点数。" },
@@ -261,13 +266,20 @@ export async function POST(request: Request) {
}
let reserveResult;
let billingRequestId: string;
try {
reserveResult = await runCreditRpc(
billingRequestId = await rectificationBillingRequestId(
accounting,
"begin_consultation_credit",
userId,
requestId,
conversation.sessionId,
);
reserveResult = await authorizeUsage(accounting, {
userId,
requestId: billingRequestId,
featureKey: "rectification",
requestedModelId: selectedModel.id,
creditCost: selectedModel.creditCost,
});
} catch (error) {
const reason = error instanceof Error ? error.name : "UnknownError";
console.error(`[agentic-rectification] credit reserve failed request=${requestId} reason=${reason}`);
@@ -277,11 +289,11 @@ export async function POST(request: Request) {
);
}
if (!reserveResult.success) {
const insufficient = reserveResult.error_code === "insufficient_credits";
const insufficient = reserveResult.reason === "insufficient_credits";
return NextResponse.json(
{
error: insufficient ? "咨询点数不足" : "暂时无法扣除咨询点数",
message: insufficient ? "请先兑换咨询点数后再继续。" : reserveResult.error_code || "请稍后重试。",
message: insufficient ? "请先兑换咨询点数后再继续。" : reserveResult.reason || "请稍后重试。",
},
{ status: insufficient ? 402 : 503 },
);
@@ -296,18 +308,37 @@ export async function POST(request: Request) {
let emitted = false;
let raw = "";
let settled = false;
const settle = async (complete: boolean) => {
if (settled) return;
const usageStartedAt = Date.now();
const settle = async (complete: boolean, usage?: Promise<{ inputTokens?: number; outputTokens?: number }>) => {
if (settled) return true;
settled = true;
try {
let settlement;
if (complete) {
await runCreditRpc(accounting, "complete_consultation_credit", userId, requestId);
const resolved = await usage;
const inputTokens = Math.max(0, Math.trunc(resolved?.inputTokens ?? 0));
const outputTokens = Math.max(0, Math.trunc(resolved?.outputTokens ?? 0));
settlement = await completeUsage(accounting, userId, billingRequestId, {
eventKey: requestId,
actualModelId: selectedModel.id,
modelConfigVersion: selectedModel.configVersion,
inputTokens,
outputTokens,
costMicrousd: Math.round((
inputTokens * (selectedModel.inputCostMicrousdPerMillion ?? 0)
+ outputTokens * (selectedModel.outputCostMicrousdPerMillion ?? 0)
) / 1_000_000),
durationMs: Date.now() - usageStartedAt,
});
} else {
await runCreditRpc(accounting, "cancel_consultation_credit", userId, requestId);
settlement = await releaseUsage(accounting, userId, billingRequestId, "rectification_cancelled");
}
if (!settlement.success) throw new Error(settlement.error_code ?? "usage_settlement_failed");
return true;
} catch (error) {
const reason = error instanceof Error ? error.name : "UnknownError";
console.warn(`[agentic-rectification] credit settle failed request=${requestId} complete=${complete} reason=${reason}`);
const reason = error instanceof Error ? error.message : "UnknownError";
console.warn(`[agentic-rectification] usage settle failed request=${requestId} complete=${complete} reason=${reason}`);
return false;
}
};
const send = (event: Record<string, unknown>) => {
@@ -335,13 +366,6 @@ export async function POST(request: Request) {
raw += chunk;
send({ type: "delta", text: chunk });
}
void recordModelUsage(
accounting,
userId,
requestId,
selectedModel.id,
result.totalUsage,
);
const reply = parseAgentReply(raw, "general");
if (!emitted || !reply.text) {
console.warn(`[agentic-rectification] empty response request=${requestId}`);
@@ -379,8 +403,12 @@ export async function POST(request: Request) {
} catch {
console.warn(`[agentic-rectification] unable to read candidate result request=${requestId}`);
}
if (!await settle(true, result.totalUsage)) {
send({ type: "error", message: "生时校正回复已生成,但用量结算失败,请稍后重试。" });
controller.close();
return;
}
send({ type: "done", emitted: true });
await settle(true);
controller.close();
} catch (error) {
const reason = error instanceof Error ? error.name : "UnknownError";