Files
Jyotisha/frontend/src/lib/consult/settlement.ts
T
jesse-uxandGrok (agent loop) cd7e0a3493
Independent Staging Quality Gate / validate (push) Successful in 13m51s
Independent Staging Quality Gate / publish (push) Successful in 4m6s
把普通对话的预留、取消、追加、寒暄结算和用量计算收进结算工厂。
完成回答、直达校正、输出审核和开流前加载仍留在路由里,使 diff 停在 1500 行以内。

Co-Authored-By: Grok (agent loop)
2026-10-15 19:12:44 +08:00

279 lines
10 KiB
TypeScript

import "server-only";
import { NextResponse } from "next/server";
import { z } from "zod";
import { mergePromptCacheUsage, promptCacheUsage } from "@/lib/agent-generation-settings";
import { logAgentObservability, type AgentSettlementResult } from "@/lib/agent-observability";
import { CreditRpcError } from "@/lib/consultation-billing";
import { consultationUsageMetadata } from "@/lib/consultation-step-timing";
import type { SmalltalkUsage } from "@/lib/consultation-smalltalk";
import { resolveFeaturePricing } from "@/lib/feature-pricing";
type RpcClient = {
rpc(name: string, args: Record<string, unknown>): Promise<{
data: unknown;
error: { message: string } | null;
}>;
};
type LatencyView = {
modelStepTimings?: Parameters<typeof consultationUsageMetadata>[0]["modelSteps"];
answerReasoningMs?: number | null;
} | null;
export type Usage = {
inputTokens?: number;
outputTokens?: number;
cache?: ReturnType<typeof promptCacheUsage>;
};
export type ConsultSettlementContext = {
accounting: RpcClient;
userId: string;
requestId: string;
sessionId: string;
visibleQuestion: string;
getUsageStartedAt: () => number;
getSelectedModel: () => {
id: string;
configVersion?: number;
inputCostMicrousdPerMillion?: number;
outputCostMicrousdPerMillion?: number;
};
getClassification: () => {
usage: SmalltalkUsage;
outcome: string | undefined;
durationMs: number | null;
};
getLatencyState: () => LatencyView;
logPrep?: () => void;
noteAppendPhase?: (status: "completed" | "failed", durationMs: number) => void;
};
export const consultationReservationSchema = z.object({
success: z.boolean(),
reservation_id: z.string().uuid().nullable(),
source: z.enum(["subscription", "credits"]).nullable(),
credits: z.number().int().nullable(),
subscription_id: z.string().uuid().nullable(),
reason: z.string().nullable(),
retry_after_seconds: z.number().int().nullable(),
});
export const consultationCompletionSchema = z.object({
success: z.boolean(),
credits: z.number().int().nullable(),
error_code: z.string().nullable().optional(),
});
export const consultationQuestionAppendSchema = z.object({
success: z.boolean(),
error_code: z.string().nullable().optional(),
});
export function first<T>(value: T | T[]): T {
return Array.isArray(value) ? value[0] : value;
}
export const detachedSettlementAttempts = 3;
// ponytail: Staging MVP ceiling—without a queue/worker, detached settlement gets only three short retries.
export async function retryDetachedSettlement<T>(action: () => Promise<T>): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= detachedSettlementAttempts; attempt += 1) {
try {
return await action();
} catch (error) {
lastError = error;
if (attempt < detachedSettlementAttempts) {
await new Promise((resolve) => setTimeout(resolve, attempt * 150));
}
}
}
throw lastError;
}
export function mergeUsage(usages: Promise<Usage>[]): Promise<Usage> {
return Promise.all(usages).then((items) => items.reduce<Usage>((total, item) => {
const usage = item && typeof item === "object" ? item as Record<string, unknown> : {};
const cache = item.cache ?? promptCacheUsage(usage);
return {
inputTokens: (total.inputTokens ?? 0) + (typeof usage.inputTokens === "number" ? usage.inputTokens : 0),
outputTokens: (total.outputTokens ?? 0) + (typeof usage.outputTokens === "number" ? usage.outputTokens : 0),
cache: mergePromptCacheUsage([total.cache, cache]),
};
}, {} as Usage));
}
export function createConsultSettlement(ctx: ConsultSettlementContext) {
const {
accounting,
userId,
requestId,
sessionId,
visibleQuestion,
getUsageStartedAt,
getSelectedModel,
getClassification,
getLatencyState,
logPrep,
noteAppendPhase,
} = ctx;
async function reserveCredit(model: { id: string }) {
const pricing = await resolveFeaturePricing(accounting, "chat.standard", model.id);
const { data, error } = await accounting.rpc("reserve_consultation_usage", {
p_user_id: userId,
p_request_id: requestId,
p_session_id: sessionId,
p_requested_model_id: model.id,
p_credit_cost: pricing.credit_cost,
});
const reservation = consultationReservationSchema.safeParse(first(data ?? []));
if (error || !reservation.success) {
throw new CreditRpcError(error?.message || "invalid_reservation_response");
}
return {
success: reservation.data.success,
credits: reservation.data.credits,
error_code: reservation.data.reason,
};
}
async function cancel(): Promise<AgentSettlementResult> {
try {
const result = await retryDetachedSettlement(async () => {
const { data, error } = await accounting.rpc("cancel_consultation_credit", {
p_user_id: userId,
p_request_id: requestId,
});
const parsedCancellation = consultationCompletionSchema.safeParse(first(data ?? []));
if (error || !parsedCancellation.success) {
throw new CreditRpcError(error?.message || "invalid_cancellation_response");
}
return parsedCancellation.data;
});
if (!result.success && result.error_code !== "request_completed") {
throw new CreditRpcError(result.error_code || "cancellation_rejected");
}
return "cancelled";
} catch (error) {
const reason = error instanceof Error ? error.name : "UnknownError";
console.error(
`[billing] cancellation failed request=${requestId} reason=${reason}`,
);
return "failed";
}
}
async function appendQuestion(): Promise<Response | null> {
let appendedQuestion: { success: boolean; error_code?: string | null };
const appendStarted = Date.now();
try {
appendedQuestion = await retryDetachedSettlement(async () => {
const { data, error } = await accounting.rpc("append_consultation_question", {
p_user_id: userId,
p_request_id: requestId,
p_session_id: sessionId,
p_question_message: { role: "user", text: visibleQuestion },
});
const parsedAppend = consultationQuestionAppendSchema.safeParse(first(data ?? []));
if (error || !parsedAppend.success) {
throw new CreditRpcError(error?.message || "invalid_question_append_response");
}
return parsedAppend.data;
});
noteAppendPhase?.("completed", Math.max(0, Date.now() - appendStarted));
} catch {
noteAppendPhase?.("failed", Math.max(0, Date.now() - appendStarted));
logPrep?.();
await cancel();
return NextResponse.json(
{ error: "暂时无法保存问题", message: "请稍后重试。" },
{ status: 503 },
);
}
if (!appendedQuestion.success) {
await cancel();
if (appendedQuestion.error_code === "session_full") {
return NextResponse.json(
{
error: "这段对话已写满",
message: "这段对话已写满,开个新对话继续吧",
code: "session_full",
},
{ status: 409 },
);
}
return NextResponse.json(
{ error: "暂时无法保存问题", message: "请稍后重试。" },
{ status: 503 },
);
}
return null;
}
async function completeSmalltalk(reply: string): Promise<void> {
const actualUsage = await usagePayload(Promise.resolve({}));
const completion = await retryDetachedSettlement(async () => {
const { data, error } = await accounting.rpc("complete_consultation_free", {
p_user_id: userId,
p_request_id: requestId,
p_session_id: sessionId,
p_response_message: { role: "assistant", text: reply, responseKind: "smalltalk" },
p_actual_usage: actualUsage,
});
const result = consultationCompletionSchema.safeParse(first(data ?? []));
if (error || !result.success || !result.data.success) {
throw new CreditRpcError(error?.message || (result.success ? result.data.error_code : "invalid_free_completion") || "free_completion_rejected");
}
return result.data;
});
logAgentObservability({ requestId, sessionId, agentVersion: "consultation-smalltalk-v1",
billingSettlementResult: completion.success ? "completed" : "failed" });
}
async function usagePayload(usage: Promise<{ inputTokens?: number; outputTokens?: number }>) {
const selectedModel = getSelectedModel();
const classification = getClassification();
const classificationUsage = classification.usage;
const classificationOutcome = classification.outcome;
const classificationDurationMs = classification.durationMs;
const latencyState = getLatencyState();
const resolved = await mergeUsage([usage, Promise.resolve(classificationUsage)]);
const usageRecord = resolved as Record<string, unknown>;
const cache = resolved.cache ?? promptCacheUsage(usageRecord);
const inputTokens = Math.max(0, Math.trunc(typeof usageRecord.inputTokens === "number" ? usageRecord.inputTokens : 0));
const outputTokens = Math.max(0, Math.trunc(typeof usageRecord.outputTokens === "number" ? usageRecord.outputTokens : 0));
const usageStartedAt = getUsageStartedAt();
return {
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,
metadata: {
...(cache ? { cache: { ...cache, hit: cache.readTokens > 0 } } : {}),
...(classificationOutcome || classificationDurationMs !== null ? { classification: {
...(classificationOutcome ? { outcome: classificationOutcome } : {}),
usageKnown: classificationUsage.inputTokens !== undefined || classificationUsage.outputTokens !== undefined,
inputTokens: classificationUsage.inputTokens ?? null,
outputTokens: classificationUsage.outputTokens ?? null,
durationMs: classificationDurationMs,
} } : {}),
...(latencyState?.modelStepTimings === undefined ? {} : consultationUsageMetadata({
modelSteps: latencyState.modelStepTimings,
answerReasoningMs: latencyState.answerReasoningMs,
})),
},
};
}
return { reserveCredit, cancel, appendQuestion, completeSmalltalk, usagePayload };
}