feat(models): secure catalog and pin model versions
This commit is contained in:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user