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
@@ -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)}}
+205
View File
@@ -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);
}
}
+82 -97
View File
@@ -7,17 +7,20 @@ import {
runConsultationWorkflow,
toAgentConsultationContext,
} from "@/mastra";
import {
languageModelConfigurationMessage,
resolveLanguageModel,
} from "@/mastra/model";
import { languageModelConfigurationMessage } from "@/mastra/model";
import { blocksPromptExtraction } from "@/lib/consult-safety";
import {
consultationEntrypointSchema,
resolveConsultationQuestion,
} from "@/lib/consultation-entrypoint";
import { CreditRpcError, runCreditRpc } from "@/lib/consultation-billing";
import {
authorizeUsage,
completeUsage,
CreditRpcError,
releaseUsage,
} from "@/lib/consultation-billing";
import { reserveConsultationModel } from "@/lib/consultation-model-selection";
import { resolveSessionLanguageModel } from "@/lib/model-catalog";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { streamTextResponse } from "@/lib/stream-text-response";
@@ -45,6 +48,7 @@ export const maxDuration = 60;
const chatRequestMetadataSchema = z.object({
requestId: z.string().uuid(),
sessionId: z.string().uuid(),
modelId: z.string().trim().min(1).max(64),
name: z.string().trim().max(80).optional().default(""),
history: z
@@ -166,38 +170,6 @@ function rangeBoundaryWorkflowContext(
};
}
async function recordModelUsage(
accounting: ReturnType<typeof createAdminSupabaseClient>,
userId: string,
requestId: string,
modelId: string,
usage: Promise<{ inputTokens?: number; outputTokens?: number }>,
) {
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(
`[billing] unable to record model usage request=${requestId} model=${modelId}`,
);
} catch (error) {
const reason = error instanceof Error ? error.name : "UnknownError";
console.warn(
`[billing] unable to read model usage request=${requestId} model=${modelId} reason=${reason}`,
);
}
}
export async function POST(request: Request) {
let supabase: Awaited<ReturnType<typeof createServerSupabaseClient>>;
let accounting: ReturnType<typeof createAdminSupabaseClient>;
@@ -232,6 +204,35 @@ export async function POST(request: Request) {
);
}
const { data: chatSession, error: chatSessionError } = await supabase
.from("chat_sessions")
.select("id,model_id,model_config_version,session_type")
.eq("id", parsed.data.sessionId)
.eq("user_id", user.id)
.maybeSingle();
if (chatSessionError) {
return NextResponse.json(
{ error: "暂时无法读取咨询会话", message: "请稍后重试。" },
{ status: 503 },
);
}
if (!chatSession || chatSession.session_type !== "consultation") {
return NextResponse.json(
{ error: "咨询会话不存在", message: "请重新进入咨询。" },
{ status: 404 },
);
}
if (!chatSession.model_id || chatSession.model_id !== parsed.data.modelId) {
return NextResponse.json(
{ error: "会话模型已经变化", message: "请刷新会话后重新发送,本次不会扣点。" },
{ status: 409 },
);
}
const sessionModel = await resolveSessionLanguageModel(
chatSession.model_id,
chatSession.model_config_version,
);
if (parsed.data.entrypoint === "birth_time_rectification") {
return NextResponse.json(
{
@@ -383,20 +384,25 @@ export async function POST(request: Request) {
return data;
},
reserve: () => reserveConsultationModel(
parsed.data.modelId,
resolveLanguageModel,
() => handoffExecution?.billingReused
chatSession.model_id,
(modelId) => sessionModel?.id === modelId ? sessionModel : null,
(model) => handoffExecution?.billingReused
? Promise.resolve({
success: true,
credits: handoffExecution.credits ?? null,
error_code: null,
})
: runCreditRpc(
accounting,
"begin_consultation_credit",
: authorizeUsage(accounting, {
userId,
requestId,
),
featureKey: "chat.standard",
requestedModelId: model.id,
creditCost: model.creditCost,
}).then((result) => ({
success: result.success,
credits: result.credits,
error_code: result.reason,
})),
),
});
} catch (error) {
@@ -499,12 +505,7 @@ export async function POST(request: Request) {
return;
}
try {
await runCreditRpc(
accounting,
"cancel_consultation_credit",
userId,
requestId,
);
await releaseUsage(accounting, userId, requestId, "consultation_cancelled");
} catch (error) {
const reason = error instanceof Error ? error.name : "UnknownError";
console.error(
@@ -513,17 +514,28 @@ export async function POST(request: Request) {
}
}
async function complete() {
const usageStartedAt = Date.now();
async function complete(usage: Promise<{ inputTokens?: number; outputTokens?: number }>) {
if (handoffExecution?.status === "ready") {
await settleHandoff(true);
return;
}
const result = 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));
const costMicrousd = Math.round((
inputTokens * (selectedModel.inputCostMicrousdPerMillion ?? 0)
+ outputTokens * (selectedModel.outputCostMicrousdPerMillion ?? 0)
) / 1_000_000);
const result = await completeUsage(accounting, userId, requestId, {
eventKey: requestId,
actualModelId: selectedModel.id,
modelConfigVersion: selectedModel.configVersion,
inputTokens,
outputTokens,
costMicrousd,
durationMs: Date.now() - usageStartedAt,
});
if (!result.success)
throw new CreditRpcError(result.error_code || "completion_rejected");
}
@@ -552,18 +564,9 @@ export async function POST(request: Request) {
].filter(Boolean).join("\n"),
},
]);
const completeAndRecordUsage = async () => {
await complete();
void recordModelUsage(
accounting,
userId,
requestId,
modelSelection.usageModelId,
result.totalUsage,
);
};
const completeWithUsage = () => complete(result.totalUsage);
const settleInterrupted = (emitted: boolean) =>
settle(emitted ? completeAndRecordUsage : cancel);
settle(emitted ? completeWithUsage : cancel);
return streamTextResponse(result.textStream, {
transformText: createBirthTimeModeOutputGuard(consultationMode, false),
mode: "mastra",
@@ -576,8 +579,8 @@ export async function POST(request: Request) {
"x-jyotish-missing-layers": "birth-minute",
"x-jyotish-birth-time-mode": consultationMode,
},
...(handoff ? { onFirstOutput: () => settle(completeAndRecordUsage) } : {}),
onComplete: () => settle(completeAndRecordUsage),
...(handoff ? { onFirstOutput: () => settle(completeWithUsage) } : {}),
onComplete: () => settle(completeWithUsage),
onError: (_error, emitted) => settleInterrupted(emitted),
onCancel: settleInterrupted,
});
@@ -626,18 +629,9 @@ export async function POST(request: Request) {
].filter(Boolean).join("\n"),
},
]);
const completeAndRecordUsage = async () => {
await complete();
void recordModelUsage(
accounting,
userId,
requestId,
modelSelection.usageModelId,
result.totalUsage,
);
};
const completeWithUsage = () => complete(result.totalUsage);
const settleInterrupted = (emitted: boolean) =>
settle(emitted ? completeAndRecordUsage : cancel);
settle(emitted ? completeWithUsage : cancel);
return streamTextResponse(result.textStream, {
transformText: createBirthTimeModeOutputGuard("unverified_birth_time", false),
mode: "mastra",
@@ -650,8 +644,8 @@ export async function POST(request: Request) {
"x-jyotish-missing-layers": workflowReceipt.missingLayers,
"x-jyotish-birth-time-mode": "unverified_birth_time",
},
onFirstOutput: () => settle(completeAndRecordUsage),
onComplete: () => settle(completeAndRecordUsage),
onFirstOutput: () => settle(completeWithUsage),
onComplete: () => settle(completeWithUsage),
onError: (_error, emitted) => settleInterrupted(emitted),
onCancel: settleInterrupted,
});
@@ -685,18 +679,9 @@ export async function POST(request: Request) {
].filter(Boolean).join("\n"),
},
]);
const completeAndRecordUsage = async () => {
await complete();
void recordModelUsage(
accounting,
userId,
requestId,
modelSelection.usageModelId,
result.totalUsage,
);
};
const completeWithUsage = () => complete(result.totalUsage);
const settleInterrupted = (emitted: boolean) =>
settle(emitted ? completeAndRecordUsage : cancel);
settle(emitted ? completeWithUsage : cancel);
return streamTextResponse(result.textStream, {
transformText: createBirthTimeModeOutputGuard(
consultationMode,
@@ -712,8 +697,8 @@ export async function POST(request: Request) {
"x-jyotish-missing-layers": workflowReceipt.missingLayers,
"x-jyotish-birth-time-mode": consultationMode,
},
...(handoff ? { onFirstOutput: () => settle(completeAndRecordUsage) } : {}),
onComplete: () => settle(completeAndRecordUsage),
...(handoff ? { onFirstOutput: () => settle(completeWithUsage) } : {}),
onComplete: () => settle(completeWithUsage),
onError: (_error, emitted) => settleInterrupted(emitted),
onCancel: settleInterrupted,
});
@@ -721,7 +706,7 @@ export async function POST(request: Request) {
await cancel();
const reason = error instanceof Error ? error.name : "UnknownError";
console.error(
`[consult] generation failed request=${requestId} model=${modelSelection.usageModelId} reason=${reason}`,
`[consult] generation failed request=${requestId} model=${selectedModel.id} reason=${reason}`,
);
return NextResponse.json(
{
+6 -2
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { publicLanguageModelCatalog } from "@/mastra/model";
import { loadLanguageModelCatalog } from "@/lib/model-catalog";
export const runtime = "nodejs";
@@ -26,7 +26,11 @@ export async function GET() {
);
}
const catalog = publicLanguageModelCatalog();
const resolved = await loadLanguageModelCatalog();
const catalog = {
models: resolved.publicModels,
defaultModelId: resolved.defaultModelId,
};
if (!catalog.defaultModelId || catalog.models.length === 0) {
return NextResponse.json(
{ error: "模型服务尚未配置", message: "当前没有可用的咨询模型。" },
@@ -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";